目录
1.2.3.等待条件满足函数 pthread_cond_wait 需要互斥量的原因
1.Linux线程同步
1.1.同步概念与竞态条件
线程互斥的设计是正确的,但线程互斥在某些场景下并不合理,有可能导致饥饿问题。
饥饿问题:某个执行流访问完临界资源后释放锁,此时相较于其他执行流,该执行流离锁更近,更容易再次申请到锁进而访问临界资源,其他执行流长时间得不到锁,无法访问临界资源的情况就是饥饿问题。
解决饥饿问题:某个执行流访问完临界资源后释放锁,此时该执行流不能再立即申请锁,而应该排到其他执行流的尾部,等到其他执行流申请锁访问完临界资源后再去申请锁。
线程同步:我们在保证线程互斥的条件下(保证临界资源安全的前提下),让线程能够按照某种特定的顺序访问临界资源,这种特性我们就叫做线程同步。
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
同步的功能:解决饥饿问题;让线程之间互相协同。
注:线程互斥和线程同步不是对立的关系,而是互相补充的关系。线程互斥保证安全,线程同步保证合理。
1.2.条件变量
条件变量是完成线程同步的重要机制。
在学习条件变量之前,哪个线程被唤醒并执行是由调度器决定的。学习了调度器,我们可以使用调度器来主动的唤醒并执行某个线程。
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。 例如:一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
1.2.1.条件变量相关接口
定义条件变量:
pthread_cond_t cond;
初始化条件变量(两种方式):
如果条件变量cond是全局变量或static修饰的静态变量,既可以直接使用宏进行静态初始化,也可以使用下面的动态初始化。
如果条件变量cond是局部变量,应采用动态初始化。
方式一:静态初始化
cond = PTHREAD_COND_INITIALIZER
方式二:动态初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数:
cond:要初始化的条件变量
attr:条件变量属性,设置为NULL即可
返回值:
成功返回0,失败返回错误号
销毁条件变量:
int pthread_cond_destroy(pthread_cond_t *cond);
参数:
cond:要销毁的条件变量
返回值:
成功返回0,失败返回错误号
注:使用 PTHREAD_COND_INITIALIZER 初始化的条件变量不需要销毁。
等待条件满足(两种方式):
方式一:
线程进行等待(在条件变量cond排队等待),如果条件满足则等待的线程被唤醒并执行。
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量
注:条件变量cond要和互斥锁mutex一并使用。
方式二:
线程进行等待(在条件变量cond排队等待),如果条件满足则线程被唤醒并执行,如果abstime时间后还没有条件满足被唤醒则线程自动醒来。
int pthread_cond_timedwait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex,const struct timespec *restrict abstime);
参数:
cond:要在这个条件变量上等待
mutex:互斥量
abstime:等待时间
唤醒等待(两种方式):
方式一:
唤醒在cond条件变量下等待的队首的线程
int pthread_cond_signal(pthread_cond_t *cond);
参数:
cond:要唤醒线程等待的条件变量
方式二:
唤醒在cond条件变量下等待的队中所有的线程
int pthread_cond_broadcast(pthread_cond_t *cond);
参数:
cond:要唤醒线程等待的条件变量
1.2.2.条件变量相关代码
代码示例1:
创建mythread.cc文件,写入下图一所示的代码,创建makefile文件,写入下图二所示的代码,使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图三所示。
创建三个线程执行waitCommand函数,每个线程在函数内部执行到pthread_cond_wait函数调用时都会在条件变量cond下排队等待,每一次调用pthread_cond_signal函数时就按照队伍顺序唤醒一个等待的线程。
从运行结果可以看出,三个线程按照一定顺序轮转着被唤醒。
注:
1.在上面的代码中没有用到互斥锁mutex,但是pthread_cond_wait函数接口需要,所以要定义。
2.cout和cin连续使用时,在cin之前会自动刷新缓冲区,将cout输出的内容打印出来。
3.在主函数while循环最后要进行sleep睡眠,因为显示器也是临界资源,这里没有对显示器临界资源做保护,主进程的cout打印和被唤醒线程的cout打印同时进行,这里sleep睡眠是为了让被唤醒线程先打印,然后主线程再打印。
代码示例2:
修改mythread.cc文件,如下图一所示。使用make命令生成mythread可执行程序,使用./mythread命令运行mythread可执行程序,如下图三所示。
创建三个线程执行waitCommand函数,每个线程在函数内部执行到pthread_cond_wait函数调用时都会在条件变量cond下排队等待,调用pthread_cond_broadcast函数唤醒条件变量cond中每一个等待的线程,对应线程被唤醒后执行方法集funcs中的所有方法。
1.2.3.等待条件满足函数 pthread_cond_wait 需要互斥量的原因
知识梳理:条件:程序员判断资源是否满足自己操作的要求条件变量:条件满足或不满足的时候,辅助wait和signal进行等待和唤醒的工具因为判断条件是否满足时,用来判断的资源往往都是共享资源,所以使用pthread_cond_wait等待的时候基本都是在临界区等待的。pthread_cond_wait 需要互斥量的原因:条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享资源,使原先不满足的条件变得满足,并且signal通知等待在条件变量上的线程。条件不会无缘无故的突然变得满足了,必然会牵扯到共享资源的变化。所以某个线程在修改共享资源时一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题,其他线程因为无法申请到锁,也就无法通过某些操作来改变共享资源。
所以在调用pthread_cond_wait函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。
当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。总结:
等待的时候往往是在临界区内等待的,当该线程进入等待的时候,互斥锁会自动释放,而当该线程被唤醒时,又会自动获得对应的互斥锁。
条件变量需要配合互斥锁使用,其中条件变量是用来完成同步的,而互斥锁是用来完成互斥的。
当pthread_cond_wait函数等待阻塞线程的时候有两个功能:一就是让线程在特定的条件变量下等待,二就是让线程释放对应的互斥锁。当pthread_cond_wait函数等待阻塞结束的时候有一个功能:让线程重新获取互斥锁。
如果pthread_cond_wait函数只有等待阻塞线程的功能,那么在等待之前我们就需要手动解锁,等待返回被唤醒之后再手动加锁,如下图所示。但是我们这样手动的写是有问题的,由于手动的写,解锁和等待是两句代码不是原子操作,调用解锁之后, pthread_cond_wait 之前,如果线程被切换,其他线程获取到互斥量pthread_cond_signal发送了条件满足信号,那么 pthread_cond_wait 将错过这个信号,对应线程被切换回来执行pthread_cond_wait 后,可能会导致线程永远阻塞在这个pthread_cond_wait。所以解锁和等待必须是一个原子操作,系统提供的pthread_cond_wait函数解锁和等待就是原子的。
// 错误的设计 pthread_mutex_lock(&mutex); if (condition_is_false) { pthread_mutex_unlock(&mutex); //解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过 pthread_cond_wait(&cond); pthread_mutex_lock(&mutex); } pthread_mutex_unlock(&mutex);
注:
1.在int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex)中; 进入该函数后,会去看条件变量等于0不?等于(条件不满足),就把互斥量变成1(解锁),直到cond_ wait返回,把条件变量改成1(条件满足),把互斥量恢复成原样(加锁)。
2.上面的代码if语句的选取也是有问题的,具体原因在下面2.2.2小节进行解释。
1.2.4.条件变量使用规范
等待条件代码:pthread_mutex_lock(&mutex); while (条件为假) pthread_cond_wait(cond, mutex); 修改条件 pthread_mutex_unlock(&mutex);
注:这里为什么用while循环而不是if语句下面2.2.2小节进行解释。
给条件发送信号代码:pthread_mutex_lock(&mutex); 设置条件为真 pthread_cond_signal(cond); pthread_mutex_unlock(&mutex);
注:唤醒等待 pthread_cond_signal(cond) 代码既可以放在解锁 pthread_mutex_unlock(&mutex) 代码前,也可以放在解锁 pthread_mutex_unlock(&mutex) 代码后,原因下面2.2.2小节进行解释。
2.生产者消费者模型
2.1.生产者消费者模型的概念
补充知识:(耦合与解耦的理解)
如下图所示,调用add函数,对两个变量a、b做加法,我们将main函数执行部分看作是主进程执行,将add函数执行部分看作是线程A执行。主进程执行main函数,执行到add(a,b)代码时,调用线程A执行add函数的加法操作,此时主进程等待线程A执行,线程A执行完后,主进程再继续往下执行。
因为在线程A执行的时候,主进程需要等待线程A执行完才会继续往下执行,所以主进程和线程A之间就是强耦合关系。
如下图所示,调用add函数,对两个变量a、b做加法,我们将main函数执行部分看作是主进程执行,将add函数执行部分看作是线程A执行。主进程执行main函数,执行到Put(a,b)代码时,将a、b两个变量的数值设置到缓冲区中,然后继续往下执行不等待,add函数通过Get函数获取缓冲区中的数据,然后执行加法操作。
因为主进程直接将要执行加法操作的数据放入了缓冲区,然后就继续往下执行了,线程A在合适的时候会获取缓冲区的数据进行加法操作,主进程和线程A各自执行各自的代码不需要等待,所以主进程和线程A之间不再具有强耦合关系,进行了解耦。
生产者消费者模型:生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过这个容器来通讯,所以生产者生产完数据之后不用等待消费者处理,直接将生产的数据放到这个容器当中,消费者也不用找生产者要数据,而是直接从这个容器里取数据,这个容器就相当于一个缓冲区,平衡了生产者和消费者的处理能力,这个容器实际上就是用来给生产者和消费者解耦的。注:1.这里容器的本质就是缓冲区,一般是内存中的一段空间,该空间有自己的组织方式(链表、队列等)。2.这里的容器存在被生产者和消费者同时访问的情况,因此容器是一个临界资源。
问题1:消费者有多个,消费者之间是什么关系?答:竞争关系,对于线程而言就是互斥关系。问题2:生产者有多个,生产者之间是什么关系?答:竞争关系,对于线程而言就是互斥关系。问题3:消费者有多个,生产者有多个,消费者和生产者之间是什么关系?答:互斥关系和同步关系,对于线程而言也是互斥关系和同步关系。互斥关系是因为在消费者看来,生产者要有确定性的结果(生产者要么不生产,要生产就要生产完,没有中间状态),保证临界资源的安全性。同步关系是因为生产和消费应该有一定的顺序,消费完了再生产,生产满了再消费,保证生产消费过程中的合理性。
生产者消费者模型的321原则:三种关系:生产者和生产者(互斥),消费者和消费者(互斥),生产者和消费者(互斥/同步),是三种关系两种角色:生产者和消费者是由线程承担的,是两种线程角色一个交易场所:容器是内存中特定的一种内存结构(数据结构),是一个交易场所
生产者消费者模型优点:解耦支持并发支持忙闲不均
2.2.基于阻塞队列的生产者消费者模型
2.2.1.基于阻塞队列的生产者消费者模型概念
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
2.2.2.实现基于阻塞队列的生产者消费者模型
为了便于理解,我们以单生产者,单消费者,来进行讲解
创建BlockQueue.hpp文件,写入下图一所示的代码,创建BlockQueueTest.cc文件,写入下图二所示的代码,创建Makefile文件,写入下图三所示的代码,使用make命令生成blockQueue可执行程序,使用./blockQueue命令运行blockQueue可执行程序,如下图四所示。
注:
1.在Makefile文件中,我们定义五个变量选项CC、FLAGS、LD、bin、src。CC表示所用编译器,FLAGS表示编译时所用标准,LD表示编译时所要链接的库,bin表示要生成的可执行文件名,src表示源文件名。那么在Makefile文件中,我们就可以使用$(变量选项)的方式进行代码编写。
2.生产者线程和消费者线程都要访问修改队列,因此队列bq_是临界资源,临界资源在访问修改时要加锁,因此生产者调用push接口和消费者调用pop接口都需要加锁执行。
3.如果生产者线程调用push接口,在push接口中加锁,然后判断队列是否满,如果此时队列是满的,那么直接解锁并退出push接口。由于生产者线程离锁更近,相比于消费者线程调用pop接口,生产者线程再次调用push接口,仍然可以抢到锁,在push接口中因为消费者线程抢不到锁队列还是满的,直接解锁并退出push接口。生产者线程离锁更近,如果重复调用push接口频繁申请锁,会导致消费者线程一直等待锁,造成了消费者线程的饥饿问题。同理,如果消费者线程离锁更近,而队列为空,重复调用pop接口频繁申请锁,也会造成生产者线程的饥饿问题。
为了解决上面的饥饿问题:生产者线程调用push接口时,push接口中如果判断队列满了,就让生产者线程条件等待,并唤醒消费者线程,如果判断队列不满,就让生产者线程生产,并唤醒消费者线程(因为这里不满有可能是队列为空,那么此时消费者线程有可能在条件等待,生产者线程生产完后需要把消费者线程唤醒);消费者线程调用pop接口时,pop接口中如果判断队列空了,就让消费者线程条件等待,并唤醒生产者线程,如果判断队列不为空,就让消费者线程消费,并唤醒生产者线程(因为这里不为空有可能是队列满了,那么此时生产者线程有可能在条件等待,消费者线程消费完后需要把生产者线程唤醒)。
4.在push函数中,proBlockWait函数执行完返回后,此时有可能条件还不满足(队列仍然为满),这里举三种可能来说明原因:proBlockWait函数存在调用失败的可能;proBlockWait阻塞等待时有可能被伪唤醒;如果有多个生产者,所有生产者都被唤醒了,其中一个生产者抢到锁并进行了生产,那么其他生产者被唤醒后条件不满足队列为满。
为了解决这个问题,我们在判断条件是否满足时,使用的是while循环而不是if判断,调用完proBlockWait函数返回后还回去while循环判断条件是否满足,如果proBlockWait函数调用失败或伪唤醒了,那么队列仍然为满条件仍不满足再继续调用proBlockWait函数等待。
pop函数也存在上面的问题,解决方法和push函数相同。
5.在push函数中,唤醒消费者线程的wakeupCon函数既可以放在解锁之前也可以放在解锁之后。
假设wakeupCon函数放在解锁之前,那么如果执行完wakeupCon函数之后还没有解锁,线程就被切换了,消费者线程被唤醒,唤醒的同时要申请锁,因为之前生产者线程还没来得及解锁,所以消费者线程这里无法申请到锁,因此消费者线程要进行等待,只不过这个消费者线程从之前的在条件变量等待变为在互斥锁等待,等到生产者线程再被执行进行解锁之后,消费者线程就可以申请到锁继续执行了,因此wakeupCon函数放在解锁之前是可以的。
假设wakeupCon函数放在解锁之后,那么如果执行完解锁之后还没有执行wakeupCon函数,线程就被切换了,此时其他的线程拿到锁后执行自己的代码,有可能会改变队列的状态,等到生产者线程再被执行进行wakeupCon函数唤醒对应消费者线程之后,对应消费者线程被唤醒,唤醒的同时要申请锁并执行,因为while循环的存在唤醒之后还要继续进行条件判断,如果队列不为空就获取,如果队列为空就继续等待,因此wakeupCon函数放在解锁之后是可以的。
pop函数同理,wakeupPro函数既可以放在解锁之前也可以放在解锁之后。
6.种随机数种子的srand代码中,只有time()函数随机性不够强,我们再按位或上主进程的pid值可以使得随机性更强。
创建BlockQueue.hpp文件,写入下图一所示的代码,创建BlockQueueTest.cc文件,写入下图二所示的代码,创建Task.hpp文件,写入下图三所示的代码,创建Makefile文件,写入下图四所示的代码,使用make命令生成blockQueue可执行程序,使用./blockQueue命令运行blockQueue可执行程序,如下图五所示。
上面我们实现了一个整数的阻塞队列,这里我们实现一个自定义类型Task类型的阻塞队列,队列里面存放的是一个个Task任务。
注:
1.在Task.hpp文件中,int operator() (){return run();}部分代码是定义了一个仿函数,在BlockQueueTest.cc文件consumer函数中的t()代码是使用该仿函数。
2.我们说生产者消费者模型支持并发一般并不是说在阻塞队列中并发(在上面的代码中生产者线程和消费者线程在阻塞队列中是互斥的不存在并发),而是说生产者线程在生产任务的同时,消费者线程可以在处理任务(生产任务和处理任务才是主要花时间的地方),因此我们说生产者消费者模型是支持并发的。
2.3.POSIX信号量
2.3.1.信号量的原理
我们将可能会被多个执行流同时访问的资源叫做临界资源,临界资源需要进行保护否则会出现数据不一致等问题。
当我们仅用一个互斥锁对临界资源进行保护时,相当于我们将这块临界资源看作一个整体,同一时刻只允许一个执行流对这块临界资源进行访问。
但实际我们可以将这块临界资源再分割为多个区域,当多个执行流需要访问临界资源时,如果这些执行流访问的是临界资源的不同区域,那么我们可以让这些执行流同时访问临界资源的不同区域,此时不会出现数据不一致等问题。
2.3.2.信号量的概念
信号量的概念:
信号量本质是一个计数器,是描述临界资源中资源数目的计数器,信号量能够更细粒度的对临界资源进行管理。
每个执行流在进入临界区之前都应该先申请信号量,申请成功就有了操作特点的临界资源的权限,当操作完毕后就应该释放信号量。
申请信号量失败被挂起等待。当执行流在申请信号量时,可能此时信号量的值为0,也就是说信号量描述的临界资源已经全部被申请了,此时该执行流就应该在该信号量的等待队列当中进行等待,直到有信号量被释放时再被唤醒。
注:
1.信号量的本质是计数器,但不意味着只有计数器,信号量还包括一个等待队列。
2.POSIX信号量和SystemV信号量作用相同,都是用于同步操作(也可用于互斥操作),达到无冲突的访问共享资源目的。 但SystemV很难用于线程间同步,POSIX可以用于线程间同步。
信号量的PV操作:
P操作:我们将申请信号量称为P操作,申请信号量的本质就是申请获得临界资源中某块资源的使用权限,当申请成功时临界资源中资源的数目应该减一,因此P操作的本质就是让计数器减一。
V操作:我们将释放信号量称为V操作,释放信号量的本质就是归还临界资源中某块资源的使用权限,当释放成功时临界资源中资源的数目就应该加一,因此V操作的本质就是让计数器加一。注:
1.PV操作必须是原子操作。多个执行流为了访问临界资源会竞争式的申请信号量,因此信号量是会被多个执行流同时访问的,也就是说信号量本质也是临界资源。但信号量本质就是用于保护临界资源的,我们不可能再用信号量去保护信号量,所以信号量的PV操作必须是原子操作。
2. 内存当中变量的++、--操作并不是原子操作,因此信号量不可能只是简单的对一个全局变量进行++、–操作。
互斥锁与信号量的关系:
互斥锁是把整个临界资源看成一个整体,线程拿到锁了就能够访问修改整个临界资源,线程拿不到锁临界资源的任何部分都无法访问修改,保证临界资源整体不能同时被两个以上的线程访问修改。信号量是把临界资源分割为多个区域,保证每个临界资源区域不能同时被两个以上的线程访问修改。
如果信号量的值只有0和1,那么信号量的P操作是把1减为0,对应加锁操作,信号量的V操作是把0加为1,对应解锁操作。也就是说互斥锁等价于二元信号量。
2.3.3.信号量函数
定义信号量:
sem_t sem;
初始化信号量:
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
sem:需要初始化的信号量。
pshared:传入0值表示线程间共享,传入非零值表示进程间共享。
value:信号量的初始值(计数器的初始值)。
返回值:
初始化信号量成功返回0,失败返回-1。
注:使用sem_init函数需要包含<semaphore.h>头文件
销毁信号量:
int sem_destroy(sem_t *sem);
参数:
sem:需要销毁的信号量。
返回值:
销毁信号量成功返回0,失败返回-1。
注:使用sem_destroy函数需要包含< semaphore.h>头文件
等待信号量(P操作、申请信号量):
int sem_wait(sem_t *sem);
参数:
sem:需要等待的信号量。
返回值:
等待信号量成功返回0,信号量的值减一。
等待信号量失败返回-1,信号量的值保持不变。
注:
1.信号量sem_wait函数是阻塞式的等待。
2.使用sem_wait函数需要包含<semaphore.h>头文件。
发布信号量(V操作、释放信号量):
int sem_post(sem_t *sem);
参数:
sem:需要发布的信号量。
返回值:
发布信号量成功返回0,信号量的值加一。
发布信号量失败返回-1,信号量的值保持不变。
注:使用sem_post函数需要包含<semaphore.h>头文件
2.4.基于环形队列的生产者消费者模型
2.4.1.基于环形队列的生产者消费者模型概念
环形队列采用数组模拟,用模运算来模拟环状特性。如下图一所示,最开始head和tail指针指向同一位置,每插入一个数据tail先指向下一个位置然后在该位置插入数据,每删除一个数据head指向下一个数据即可。但是环形队列空和满的状态是一样的,都是head==tail,因此无法判断为空或者为满。为解决这个问题,可以通过加计数器或者标记位来判断满或者空,另外也可以预留一个空的位置,作为满的状态。然而,对于 基于环形队列的生产者消费者模型,我们现在有信号量这个计数器,可以很简单的进行多线程间的同步过程。这里的数组就是临界资源,生产者线程往数组push插入数据,消费者线程从数组pop删除数据。对于数组(临界资源)来说,如果数组不为满或空时,那么生产者线程正在push的数据位置和消费者线程正在pop的数据位置是在数组的不同位置,那么这两个线程是可以同时访问修改数组的;如果数组为满或空时,那么生产者线程正在push的数据位置和消费者线程正在pop的数据位置是在数组的同一位置,那么生产者线程和消费者线程只能有一个访问修改数组,此时生产者线程和消费者线程需要保持互斥和同步。
注:
1.当数组是不为空或满的状态时,那么生产者线程和消费者线程在数组(临界资源)中也支持了并发。
2.对于基于环形队列的生产者消费者模型而言,临界资源对于线程的互斥和同步只需要在数组为满或空的状态下保持。3.当环形队列为空时,而生产者线程和消费者线程同时访问环形队列,应该让生产者线程先运行;当环形队列为满时,而生产者线程和消费者线程同时访问环形队列,应该让消费者线程先运行。
2.4.2.实现基于环形队列的生产者消费者模型
创建RingQueue.hpp文件,写入下图一所示的代码,创建RingQueueTest.cc文件,写入下图二所示的代码,创建Makefile文件,写入下图三所示的代码,使用make命令生成ringQueue可执行程序,使用./ringQueue命令运行ringQueue可执行程序,如下图四所示。
注:
1.当生产者线程和消费者线程同时访问环形队列时,如何控制生产者线程先运行还是消费者线程先运行,是使用信号量来保证的。
生产者线程最关心的是空间资源,消费者线程最关心的是数据资源。假设环形队列有N个节点,最开始空间资源为N,最开始数据资源为0。用sem_t roomSem表示空间资源信号量,初始化为N,用sem_t dataSem表示数据资源信号量,初始化为0。
生产者线程每次生产时,首先要执行P(roomSem)操作申请空间资源信号量,然后进行生产操作向环形队列放数据,最后执行P(dataSem)操作释放数据资源信号量。消费者线程每次消费时,首先要执行P(dataSem)操作申请数据资源信号量,然后进行消费操作从环形队列拿数据,最后执行P(roomSem)操作释放空间资源信号量。
这样生产者线程和消费者线程之间,各自申请自己所关心的资源,各自释放对方所关心的资源,生产者线程和消费者线程的步调就可以协同起来了。
最开始环形队列为空时,roomSem空间资源信号量为N,dataSem数据资源信号量为0。假设消费者线程先执行,首先要申请dataSem,dataSem此时为0,消费者线程被挂起;生产者线程后执行,首先要申请roomSem,roomSem此时为N,生产者线程申请空间资源成功进而生产资源,最后释放dataSem。此时dataSem为1,被挂起的消费者线程被唤醒,申请dataSem进而进行消费工作。所以最开始环形队列为空时,保证了生产者线程先运行。
当环形队列为满时,roomSem空间资源信号量为0,dataSem数据资源信号量为N。假设生产者线程先执行,首先要申请roomSem,roomSem此时为0,生产者线程被挂起;消费者线程后执行,首先要申请dataSem,dataSem此时为N,消费者线程申请数据资源成功进而消费资源,最后释放roomSem。此时roomSem为1,被挂起的生产者线程被唤醒,申请roomSem进而进行生产工作。所以当环形队列为满时,保证了消费者线程先运行。
2.这里的代码是单生产者单消费者,如果环形队列为满或空,保证了生产者线程和消费者线程在环形队列的互斥和同步。如果是多生产者多消费者,还需要保证任何情况下生产者线程之间和消费者线程之间的互斥,此时RingQueue的成员变量pIndex_和cIndex_也是临界资源,需要用锁来保护,我们分别定义pmutex_和cmutex_成员变量来保护pIndex_和cIndex_临界资源。
生产者线程和消费者线程先申请信号量,如果申请到了信号量,那么对应线程就一定可以进行生产或消费工作,但是申请到roomSem信号量的生产者线程之间或申请到dataSem信号量的消费者线程之间谁先进行生产或消费工作是由锁决定的,生产者线程之间谁先申请到了pmutex_锁,那么谁就先进行生产工作,消费者线程之间谁先申请到了cmutex_锁,那么谁就先进行消费工作。因此在push和pop接口中,sem_wait信号量申请函数应该放在pthread_mutex_lock加锁函数前面,那么对应sem_post信号量释放函数应该放在pthread_mutex_unlock解锁函数后面。
3.多生产者消费者的情况下,生产者线程之间(消费者之间)是互斥的,必须串行执行生产(消费)工作,那么生产者消费者模型的并发体现在哪呢?这个问题前面我们讲过。不要只关心把数据或任务从环形队列放拿的过程,生产数据或任务和消费数据或任务,也是需要花时间的,生产者线程或消费者线程执行push或pop操作虽然不能并发执行,但是生产和消费的过程是可以并发的。
3.线程池
3.1.线程池的概念
线程池:
一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。
线程池优点:
线程池避免了在处理短时间任务时创建与销毁线程的代价。
线程池不仅能够保证内核充分利用,还能防止过分调度。
注:线程池中可用线程的数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
线程池的应用场景:需要大量的线程来完成任务,且完成任务的时间比较短。WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。
3.2.线程池的实现
3.2.1.线程池的实现
下面我们实现一个简单的线程池,线程池的结构是一个生产者消费者模型,线程池中提供了一个任务队列,以及若干个线程(多线程)。如下图所示,线程池中的多个线程负责从任务队列当中拿任务,并将拿到的任务进行处理,线程池对外提供一个Push接口,用于让外部线程能够将任务Push到任务队列当中。
创建ThreadPool.hpp文件,写入下图一所示的代码,创建ThreadPoolTest.cc文件,写入下图二所示的代码,创建Task.hpp文件,写入下图三所示的代码,创建Log.hpp文件,写入下图四所示的代码,创建Makefile文件,写入下图五所示的代码,使用make命令生成threadpool可执行程序,使用./threadpool命令运行threadpool可执行程序,如下图六所示。
注:
1.类内的成员函数都有默认参数this,因此线程要执行的threadRoutine函数应该设置成static静态的(静态的成员函数属于整个类,因此没有this指针)。
这样有一个问题,没有了this指针,对应线程在执行threadRoutine函数时,在函数内部并不知道执行的是哪个线程池的threadRoutine函数,进而无法去对应的线程池中获取任务。我们可以在pthread_create函数中将this指针作为参数传给threadRoutine函数,那么此时在threadRoutine函数内部就拿到了对应的线程池对象。
2.线程池的任务队列是临界资源,当多个线程向线程池任务队列push放任务时,各线程之间放任务应该是互斥的,当多个线程从线程池任务队列pop拿任务时,各线程之间拿任务也应该是互斥的,因此在向线程池任务队列放任务和拿任务的过程中,即在push、pop函数内部或调用push、pop函数时,应该用互斥锁来进行保护。
3.2.2.线程池的实现(懒汉方式实现单例模式优化版)
创建ThreadPool.hpp文件,写入下图一所示的代码,创建ThreadPoolTest.cc文件,写入下图二所示的代码,创建Task.hpp文件,写入下图三所示的代码,创建Log.hpp文件,写入下图四所示的代码,创建Lock.hpp文件,写入下图五所示的代码,创建Makefile文件,写入下图六所示的代码,使用make命令生成threadpool可执行程序,使用./threadpool命令运行threadpool可执行程序,并使用 ps -al | grep -E 'master|follower' 命令作为监视脚本进行监视(命令中用 | 符号连接master和follower,表示为匹配到master或follower线程。-E选项表示使用正则匹配方式),如下图七所示。
注:1.懒汉方式实现单例模式时,如果getInstance函数不用互斥锁保护,会 存在一个严重的问题, 线程不安全。第一次调用 getInstance 函数 的时候 , 如果两个线程同时调用 , 可能会创建出两份 类 对象的实例, 但是后续再次调用, 就没有问题了。因此我们要在getInstance函数中 使用互斥锁来保证线程安全,这个互斥锁应该用static修饰定义成全局的,这样各线程调用getInstance 函数时使用的都是同一把mutex锁。2.prctl函数可以修改对应线程的名字,其声明如下图所示,第一个参数option设置为PR_SET_NAME,第二个参数为要修改成的线程名。
使用prctl函数需要包含<sys/prctl.h>头文件。
4.STL标准库和智能指针的线程安全问题
问题:STL标准库 中的容器是否是线程安全的 ?答: 不是 。 原因是 STL 的设计初衷是将性能挖掘到极致 , 而一旦涉及到加锁保证线程安全 , 会对性能造成巨大的影响 。 而且对于不同的容器 , 加锁方式的不同 , 性能可能也不同 ( 例如 hash 表的锁表和锁桶 )。 因此 STL 默认不是线程安全 . 如果需要在多线程环境下使用 , 往往需要调用者自行保证线程安全。
问题:智能指针是否是线程安全的 ?答:对于 unique_ptr, 由于只是在当前代码块范围内生效 , 因此不涉及线程安全问题 。对于 shared_ptr, 多个对象需要共用一个引用计数变量 , 所以会存在线程安全问题 。 但是标准库实现的时候考虑到了这个问题, 基于原子操作 (CAS) 的方式(是一种硬件级别保证原子性的方式)保证 shared_ptr 能够高效 且原子的操作引用计数,因此shared_ptr是线程安全的。
5.其他常见的各种锁
5.1.锁的分类
悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。互斥锁和二元信号量都是悲观锁。乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。自旋锁是一种乐观锁(自旋锁下面介绍)。注:CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。CAS操作是自旋锁的核心操作。
5.2.自旋锁介绍
5.2.1.自旋锁的概念
自旋锁的介绍:
自旋锁是为实现保护共享资源而提出一种锁机制。其实自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,“自旋”一词就是因此而得名。
注:
1.自旋锁自旋检测的过程是由库实现的,其在底层自动进行轮询检测,在上层不需要程序员手动的实现自旋检测。
2.自旋锁自旋检测的过程非常消耗CPU资源。
互斥锁、自旋锁的的选取:
临界区需要用锁进行保护,选取互斥锁还是自旋锁进行保护是由临界区代码的执行时间来决定的。当临界区代码执行时间较长时,应当选取挂起等待的互斥锁来进行保护,当临界区代码执行时间较短时,应当选取轮询检测的自旋锁来进行保护。
5.2.2.自旋锁相关接口
定义自旋锁:
pthread_spinlock_t lock;
初始化自旋锁:
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
参数:
lock:要初始化的自旋锁
pshared:自旋锁属性
返回值:
成功返回0, 失败返回错误号
销毁自旋锁:
int pthread_spin_destroy(pthread_spinlock_t *lock);
参数:
lock:要销毁的自旋锁
返回值:
成功返回0, 失败返回错误号
自旋锁加锁:
int pthread_spin_lock(pthread_spinlock_t *lock);
参数:
lock:要加锁的自旋锁
返回值:成功返回0, 失败返回错误号
自旋锁解锁:
int pthread_spin_unlock(pthread_spinlock_t *lock);
参数:
lock:要解锁的自旋锁
返回值:成功返回0, 失败返回错误号
6.读者写者问题
6.1.读写锁的概念
在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁。注:写独占,读共享,读锁优先级高
读者写者问题的321原则:
三种关系:写者和写者(互斥),读者和读者(没关系),读者和写者(互斥),是三种关系两种角色:读者和写者是由线程承担的,是两种线程角色一个读写场所:容器是内存中特定的一种内存结构(数据结构),是一个读写场所
读者写者问题和生产者消费者模型的本质区别:消费者会把数据拿走,而读者不会。
6.2.读写锁相关接口
定义读写锁:
pthread_rwlock_t rwlock;
初始化读写锁:
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t *restrict attr);
参数:
rwlock:要初始化的读写锁
attr:读写锁属性,设置为null即可
返回值:
成功返回0, 失败返回错误号
销毁读写锁:
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
参数:
rwlock:要销毁的读写锁
返回值:
成功返回0, 失败返回错误号
读写锁加锁:
1.以读方式加锁(加读锁):
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
参数:
rwlock:要加锁的读写锁
返回值:成功返回0, 失败返回错误号
2.以写方式加锁(加写锁):
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
参数:
rwlock:要加锁的读写锁
返回值:成功返回0, 失败返回错误号
读写锁解锁:
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
参数:
rwlock:要解锁的读写锁
返回值:成功返回0, 失败返回错误号
代码示例:
创建readerAndWriter.cc文件,写入下图一所示的代码,创建makefile文件,写入下图二所示的代码,使用make命令生成readerAndWriter可执行程序,使用./readerAndWriter命令运行readerAndWriter可执行程序,如下图三所示。
6.3.读写锁原理
下图一是读者加解锁的伪代码,下图二是写者加解锁的伪代码。
这样每有一个读者要加锁,那么加锁然后readers++然后解锁,进而执行读操作,这样就可以多个读者并发的进行读操作。每有一个写者要加锁,那么首先要加锁,如果readers为0说明没有读者读了,就可以进行写操作,写操作完成后解锁,如果readers不为0说明还有读者在读,那么释放锁等待,等到readers为0没有读者读了再加锁执行写操作,写操作完成后解锁。
上面的伪代码实现起来容易造成写者饥饿问题,如果不停的有读者来读那么写者就需要一直等待。
其实读写锁可以设置读者优先策略或写者优先策略,上面读者写者加解锁的伪代码可以看作是一种读者优先策略,读写锁默认是读者优先的。写者优先策略就是当有写者要写时,先阻拦准备进行读操作的读者,并且需要等待当前正在读的读者进行完读操作,当正在读的读者全部读完后再进行写操作,写完之后就不再对准备进行读操作的读者阻拦。
注:其实在读者写者问题中,写者写的很少,绝大部分时间都是读者在读,写者饥饿问题影响不大,写者延迟一会再进行写操作可以接受。POSIX库中读写锁的读者优先策略和写者优先策略表现出的效果区别很小,我们使用读写锁时使用读写锁的默认策略(默认读者优先)即可。