1 线程的概念:
进程内部的一条执行序列(执行流,有序指令的集合),一个进程可以包含多条线程,至少有一条线程,就是main方法所代表的这条执行流- -主线程。
进程中的线程仅仅是有一个独自的栈区空间(区别于栈帧)(包含了线程执行的数据)
ps:
- 进程中的线程是共享进程地址空间,每个线程都有自己独立的栈区,但是共享所有的.text .data .rodata .bss .heap
- 无论哪个线程打开一个文件,其他线程只要能够获取到对应的文件描述符,就可以访问这个文件。
2 线程与进程的区别:
1.进程是系统进行资源分配(内存,文件,网络)的最小单位,线程是CPU(CPU指向的最小单位是指令)调度执行的最小单位;
2.线程在进程的内部,所有的线程共享地址空间,进程之间都是相互独立的地址空间
2.进程是拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈
3.线程统一具有就绪,阻塞和执行三种基本状态,同样具有状态之间的转换关系(把着部分的功能归为线程)
3.进程通信 --管道、信号量、共享内存、消息队列 线程通信 --全局、堆区
4 多线程的执行是不安全的,多进程的执行是相对比较安全的
4.线程相对能减少并发执行的时间和空间的开销:
1.线程的创建时间比进程短;(进程在创建时还需创建其他的管理信息,如内存怎么管理,打开的文件怎么管理,而线程的创建直接从用所属进程已经管理好的资源)
2.线程的终止时间比进程短;
3、同一进程内的线程切换时间比进程短;(内存管理,线程具有同一个内存空间,属于同一个进程的页表的随从线程拥有同一个页表,不需要切换页表,而进程的切换需要切换页表,涉及到访问的地址空间不同,里面的cache,tob信息等硬件信息都会无效,需重新加载)
4.由于同一进程的各线程间共享内存和文件资源,可直接进行不通过内核的通信;
3 线程的实现:
1.用户线程:
在用户空间实现(OS没有线程的概念,所以任何操作系统上都能用,但它没有专门的数据结构TCP、调度器来管理线程,只是将线程认为和其他进程共享某些资源的一个普通进程,所以LINIUX的每一个线程都有一个task_struct结构。(一对一,所以若是发生线程阻塞,则OS将切换别的进程处理),是由用户态的应用程序的库(线程库)来实现)
在用户空间实现的线程机制,它不依赖OS的内核,由一组用户的线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度,OS只能看到线程所属的进程。
**2.内核线程:**在内核中实现(OS管理的线程),由内核来维护进程和线程的上下文切换信息
TCB放在内核里,CPU的调度单位是线程,由OS的内核来完成线程的管理,包括线程的创建、终止,进程主要是资源的管理,一个PCB会管理一系列线程,所有属于这个进程的线程都被PCB统一管理,但是在调度上是由TCP完成的,但完成一次切换,就需要一次用户态到内核态的切换,开销大,但在一个进程中,如果某个内核线程发起系统调用而阻塞,并不会影响其他内核线程的运行,时间片分配给线程
3.轻量级进程(混合级线程):在内核中实现,支持用户线程(solaris/linux)一个进程可有一个或多个轻量级进程,每个量级进程由一个单独的内核线程来支持。(结合内核级线程与用户即线程的优点)
用户线程与内核线程的对应关系:
Linux系统线程(轻量级的进程–LINUX系统而言)的实现:
linux实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念,但其是内核级线程。Iinux 把所有的线程都当做进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程(若是设置进程则不会设置相关共享参数)。每个线程都拥有唯一隶属于自己的 task_struct,所以在内核中,它看起来就像是一个普通的进程(只是线程和其他一些进程共享某些资源,如地址空间)。
ps:创建线程和创建进程内核调用(do_fork)的方法是一致的,只是传递的参数标记不同。
4 线程的创建
#include <pthread.h>
int pthrcad_crcate(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *),void *arg);//用于创建线程并启动线程。
//thread :接收创建的线程的ID
//attr:指定线程的属性 NULL为默认属性
//start_routinc:指定线程函数(线程所要指向的指令序列) 指令序列--函数
//arg:给线程函数传递的参数
//成功返回0,失败返回错误码
创建线程的断言:assert(res == 0);
#include<pthread.h>
#include<assert.h>
#include<stdio.h>
void * fun(void *arg)
{
int a=(int)arg;
printf("a=%d\n",a);
int i=0;
for(;i<5;++i)
{
sleep(1);
printf("fun running\n");
}
}
int main()
{
pthread_t thread;
int a=10;
int res=pthread_create(&thread,NULL,fun,(void *) a); //fun只是一个函数名称,给定了函数的地址
//调用一个函数的方法是 fun() 说明是创建一个新的线程,新线程执行的函数就是fun
assert(res==0);
int i=0;
for(;i<5;i++)
{
sleep(1);
printf("main running\n");
}
return 0;
}
编译:
gcc -o thread thread.c
会出现如下问题:
头文件是函数的声明,线程方法的定义(实现)是在专有的线程库中(不在C标准库) 在编译时需要加-lpthread (共享库)
gcc -o thread thread.c -lpthread //多线程的编程时,需加上-lpthread 将线程库连接一下
给线程函数传参:
- 值传递 将一个值强转成void* 类型 void* 类型 在32位OS 只有4字节 若传参大于4字节需要地址传递
- 地址传递 将一个变量的地址转换为void *类型 将传递的栈区的地址空间在主线程和函数线程之间共享
线程的结束:
main方法结束,默认会调用exit方法(结束进程,也杀死了所有线程(线程是依附于进程的))
所以需要
pthread_exit(void *result); //线程结束的方法,主线程结束也不会影响其他线程的执行(进程还在)。
int pthread_join(pthread_t thread,void ** result);//等待对应thread的线程的结束,接收result
//会阻塞调用的线程,直到thread指定的线程结束(串行,类似函数调用) 可用于得到指定线程结束信息(状态),也很少使用
5 线程的并发
#include<stdio.h>
#include<pthread.h>
int g=0;
void * fun(void * arg)
{
int i=0;
for(;i<1000;i++)
{
printf("g=%d\n",g++);
}
}
int main()
{
pthread_t thread[5];
int i=0;
for(;i<5;i++)
{
pthread_create(thread,NULL,fun,NULL);
}
sleep(5);
}
ps:
- 线程对g(.data、bss)是共享的
- 线程的栈不共享(值传递参数传递不共享,除非利用地址传递)
- 线程的并发执行导致每一次输出的结果变的不一定。如上例,g输出可能为5000、4999… 因为线程的并发,如若两个线程对同一个g++,但是无效的,写入内存的只有一次 。
- 所以需要对线程的并发进行保护。
6 线程同步
原因:
线程是并发执行的(导致发生竞争资源),需要线程配合执行,所以需要同步方式。
进程中的线程是共享进程地址空间,每个线程都有自己独立的栈区,但是共享所有的.text .data .rodata .bss .heap,但是正因为这种共享进程的地址空间,使得两个线程可能会同时访问同一个共享资源(地址空间)(同一个进程的虚拟空间映射在同一块物理地址),会发生资源竞争。
互斥相关的概念:
- 临界资源:多线程执行流共享的资源
- 临界区(critical):临界区是指进程中的一段需要访问共享资源并且当另一个进程处于相应代码区域时便不会被执行的代码区域
- 互斥:当一个进程处于临界区并访问共享资源时,没有其他进程会处于临界区并且访问任何相同的共享资源
- 死锁:两个或以上的进程,在相互等待完成特定任务,而最终没法将自身任务进行下去
- 饥饿:—个可执行的进程,被调度器持续忽略,以至于虽然处于可执行状态却不被执行
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
同步的方式:
6.1. 互斥锁(互斥量)(加锁和解锁一般在同一个线程中)
互斥锁–只有两种状态值:加锁状态和解锁状态
使用临界资源之前,先对互斥锁执行加锁操作,如果锁是加锁状态,则加锁操作阻塞,直到有对其加锁过的线程执行了解锁操作。
使用临界资源之后,需要对互斥.锁执行解锁操作。|
互斥锁的使用:(原子操作) (线程中锁是共享的)
//互斥锁的类型:pthread_mutex_t(一般声明在全局,共享锁)
int pthread_mutex_init(pthread_mutex_t *mutex,pthread_mutexattr_t * attr);//动态初始化
//初始化:必须在使用锁之前执行,一般就在main方法执行起来后就执行锁的初始化
pthread_mutex_t mutex =PTHREAD_ MUTEX _INITIALIZEK//静态分配
int phtread_mutex_lock(pthread_mutex_t *mutex); //加锁 mutex 锁名称
int pthread_mutex_unlock(pthread_mutex_t *mutex);//解锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);//销毁
ps:销毁互斥量需要注意:
- 使用PTHREAD_ MUTEX _INITIALIZEK初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
主线程和函数线程模拟访问打印机,主线程输出第一个字符‘a’表示开始使用打印机,输出第二个字符‘a’表示结束使用,函数线程操作与主线程相同。(由于打印机同一时刻只能被一个线程使用,所以输出结果不应该出现 abab)
#include<unistd.h>
#include<string.h>
#include<pthread.h>
#include<assert.h>
#include<stdio.h>
pthread_mutex_t t;
void * fun(void *arg)
{
pthread_mutex_lock(&t);
pri((char *)arg);
pthread_mutex_unlock(&t);
pthread_exit(NULL);
}
void pri(char *s)
{
printf("%s\n",s);
sleep(1);
printf("%s\n",s);
}
int main()
{
pthread_t thread;
pthread_mutex_init(&t,NULL);
int res=pthread_create(&thread,NULL,fun,"A");
assert(res==0);
pthread_mutex_lock(&t);
pri("B");
pthread_mutex_unlock(&t);
pthread_mutex_destroy(&t);
pthread_exit(NULL);
return 0;
}//程序有警告..
但若是有两台打印机,如果是两个线程可以直接并行使用两台打印机,加单锁操作就不合适了
6.2. 信号量(线程级的信号量,比lock机制更高级更广泛)
特殊的计数器,>0 记录临界的个数 ==0,没有临界资源可用(当信号量的值大于0时,表示可以访问的临界资源的个数,当信号量的值等于0时,对信号量执行P操作会被阻塞,直到一个线程对其做了V操作,然后会被唤醒。)(一个线程在生产资源,另一个线程在消费资源)
对信号量的P、V操作不一定非得在同一个线程中。
信号量的实现(不同于进程是OS的操作需要封装,其作用于线程已经封装好了)
#include<semaphore.h>
//信号量的类型:sem_t 全局定义一个sem_t类型的信号量
int sem_init(sem_t *sem,int shared,int init_val);//初始化
//share:设置信号量是否在进程间共享,LINUX不支持,所以一般给0;
//init_val:信号量的初始值
int sem_wait(sem_t *sem); //P操作 信号量-1 wait表示等待,所以肯定是P操作
int sem_post(sem_t *sem);//V操作 信号量+1
int sem_destroy(sem_t *sem);
信号量一般控制一个方向
例子:主线程获取用户输入,函数线程将用户输入的数据存储到a.txt中。
分析:
1、主线程获取用户输入的数据如何传递给函数线程:全局 堆区 栈区(传地址实参)
2、函数线程只能在主线程获取到数据时,才能指向write 操作 sem2
3、主线程只能在函数线程将数据写入文件之后才能获取下一次数据 sem1
#include<semaphore.h>
#include<stdio.h>
#include<string.h>
#include<fcntl.h>
#include<unistd.h>
#include<assert.h>
#include<pthread.h>
#include<time.h>
#define buffSIZE 128
sem_t sem1;//函数线程对主线程 避免覆盖,写后才能读 p操作
sem_t sem2;//主线程对函数线程 读入后才能写 需要先V操作
void * fun(void *arg)
{
srand((unsigned int )time(NULL));
char *buff= (char *)arg;
int fd=open("a.txt",O_WRONLY|O_CREAT|O_TRUNC,0664);
assert(fd!=-1);
while(1)
{
sem_wait(&sem2);//等待读取后写入
if(strncmp(buff,"end",3)==0) //结束条件
{
break;
}
int n=rand()%3;
sleep(n);//使写操作的时间加长
write(fd,buff,strlen(buff));
memset(buff,0,buffSIZE);
sem_post(&sem1);
}
pthread_exit(NULL);
}
int main()
{
char buff[buffSIZE]={};
sem_init(&sem1,0,1);
sem_init(&sem2,0,0);
pthread_t thread;
int res=pthread_create(&thread,NULL,fun,(void *)buff);//创建并启动
assert(res==0);
while(1)
{
sem_wait(&sem1);//避免覆盖,写后才能读
printf("please input: ");
fgets(buff,buffSIZE,stdin);
sem_post(&sem2);
if(strncmp(buff,"end",3)==0) //结束条件
{
break;
}
}
pthread_join(thread,NULL);
sem_destroy(&sem1);
sem_destroy(&sem2);
pthread_exit(NULL);
}
6.3. 条件变量(生产者与消费者)
条件变量提供了一种线程间的通知机制:当某个共享数据达到某个值的时候,唤醒等待这个共享数据的线程。(为多个线程提供了一个汇合的场所),多个线程都在等待条件发生,即将多个线程放在该条件队列(一般先进先出)上
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
在队列里操作。
int pthread_cond_init(pthread_cond_t *cond,pthred_condattr_t *attr);//初始化
int pthread_cond_wait(pthread_cond_t * cond,pthread_mutex_t*mutex);//等待条件变量函数
//lock(&mutex); 有两重锁 一重是队列的 一重是线程的?
//wait(&cond,&mutex);在加锁状态下,将线程添加到条件变量对应的等待队列中,(保证线程在队列中添加是互斥的,添加过程和不会发生添加或唤醒),添加完毕后执行解锁操作(避免线程堵塞)
//unlock(&mutex);
int pthread_cond_signal(pthread_cond_t * cond);//唤醒单个线程
//同样如果被唤醒,退出wait之前需要进行加锁操作
//lock(&mutex);
//signal(&cond)//唤醒操作也是互斥的,与对条件变量指向wait操作互斥
//unlock(&mutex);
int pthread_cond_broadcast(pthread_cond_t * cond);//唤醒等待在该条件上的所有线程
int pthread_cond_destroy(pthread_cond_t * cond);//唤醒单个线程
为什么pthread_cond_ wait需要互斥量? 对条件变量加锁
·条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足**,所以必须要有一个线程通过某些操作(唤醒),改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程**。
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
6.4. 读写锁(与互斥量类似,但有更高的并行性,读写锁非常适合于对数据结构读的次数远大于写的情况)
互斥量要么是锁住状态要么是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有三种状态:读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。
pthread_rwlock_t 读写锁类型
int pthread_rwlock_init(pthread_rwlock_t * rwlock,pthread_rwlockattr_t * attr);//初始化
int pthread_rwlock_rdlock(pthread_rwlock_t * rwlock);//读加锁 (如果当前写加锁是解锁的,读加锁不会阻塞(体现了更高的并发性))
//读写锁可以有三种状态:读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁
//当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写谈在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是如果线程希望以写模式对此锁进行加锁,它必须阻塞直到所有的线程释放读锁。虽然渎写锁的实现各不相同,但当读写锁处于读模式锁住状态时,如果有另外的线程试图以写模式加锁,读写锁通常会阻塞随后的读模式锁请求。这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。
int pthread_rwlock_rwlock(pthread_rwlock_t * rwlock);//写加锁
int pthread_rwlock_unlock(pthread_rwlock_t * rwlock);//解锁
int pthread_rwlock_destroy(pthread_rwlock_t * rwlock);
ps:**读写锁非常适合于对数据结构读的次数远大于写的情况。**当读写锁在写模式下时,它所保护的数据结构就可以被安全地修改,因为当前只有一个线程可以在写模式下拥有这个锁。当读写锁在读模式下时,只要线程获取了读模式下的读写锁,该锁所保护的数据结构可以被多个获得读模式锁的线程读取。
读写锁也叫做共享-独占锁,当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。
7 线程安全
原因:线程是并发运行的,而且线程之间共享进程的地址空间,所以在执行时,可能会修改共享的数据,所以操作最后的结果有不确定性。
所以一般进行如下操作:
- 使用线程同步
- 不使用的共享数据(将使用共享的数据转换为非共享数据)
#include<pthread.h>
#include<semaphore.h>
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
void * fun(void * arg)//方法中产生了共享变量,造成了线程不安全
{
char buff[128]={"a b c d e f g"};
char *q =NULL;//用q替代之前的那个静态量
char *p-strtok_r(buff," ",&q);
//char *p=strtok(buff," ");//用一个数据将切割的状态记录下来,该状态数据是共享的,可被NULL所共享!
while(p)
{
printf("fun:: %s\n",p);//传NULL的原因:让strtok接着使用上一次的状态继续切割
p=strtok(NULL," ");//下次在调用strtok需要使用上一次保存的状态,则该状态已经跳出了函数的生命期成为共享数据(内部使用了static)
sleep(1);
}
}
int main()
{
pthread_t id;
int res=pthread_create(&id,NULL,fun,NULL);
assert(res==0);
char buff[128]={"1 2 3 4 5 6 7"};
char *q =NULL;
char *p-strtok_r(buff," ",&q);
//char * p=strtok(buff," ");//使用strtok_r 可重用
while(p)
{
printf("fun:: %s\n",p);
p=strtok(NULL," ");
sleep(1);
}
pthread_exit(NULL);
}
不保证线程安全的库函数:
需要使用函数的可重用方法
8.线程与fork
多线程中某个线程调用fork(),子进程会有和父进程相同数量的线程吗?
不会,多线程中一个线程调用fork创建子进程,子进程中只有调用fork这个线程被启动运行,其他的线程不会运行。
父进程被加锁的互斥锁fork后在子进程中是否已经加锁?
是,多线程中调用fork创建子进程,子进程会继承父进程的锁的状态,父进程中所有对锁的操作都影响不了子进程的锁的状态变化。则就会出现死锁。(线程中锁共享,进程中不共享)所以,在多线程中执行fork之前,需要调用pthread_atfork方法。保证继承的锁绝对是解锁状态的
pthread_atfork方法是一个注册方法,注册了三个函数,这三个函数会在不同时间执行。
第一个参数函数由fork方法启动,在fork真正执行之前被调用,对所有的锁执行加锁操作。
第二个参数函数,在fork执行结束之后被调用,在父进程空间中执行,对所有的锁执行解锁操作。
第三个参数函数,在fork执行结束之后被调用,在子进程空间中执行,对所有的锁执行解锁操作。
ps:
atfork注册这三个方法,以及其调用时机,都是为了保证在fork执行过程中,所有的锁都是加锁状态的。没有其他线程会加锁成功。fork完成之后,在主动将所有的锁,分别在父进程空间和子进程空间解锁。fork之后,父子进程的使用的锁都是解锁状态的。