Linux多线程编程

pthread库的使用

线程的创建和终止

线程函数功能类似的进程函数
pthread_create创建一个线程fork
pthread_exit线程退出exit
pthread_join等待线程结束并回收内核资源wait
pthread_self获取线程idgetpid

pthread库函数的错误处理

补充:新版的errno不是全局变量

为了支持多线程,POSIX标准要求errno必须是线程安全的。这意味着每个线程需要有自己独立的errno实例,避免不同线程之间的干扰。因此,实现上errno可能不再是一个简单的全局变量,而是通过线程局部存储(Thread-Local Storage, TLS)来实现,每个线程拥有自己的errno副本。

尽管如此,返回值表示错误原因的设计已经固定下来了,考虑到兼容性,也不会做修改了。

线程的创建 并发性

线程的并发性

线程主动退出

获取线程退出状态

线程的取消和资源清理

线程的取消

线程资源清理

线程资源清理

为了让线程无论在什么位置终止,都能够根据实际持有的资源情况来执行资源释放操作。pthread库引入了资源清理机制:

pthread_cleanup_pushpthread_cleanup_pop函数可以用来管理资源清理函数栈。

pthread_cleanup_push负责将清理函数压栈,这个栈会在下列情况下弹出:

值得特别注意的是:当线程在start_routine中执行return语句而终止的时候,清理函数不会弹栈!

POSIX要求pthread_cleanup_pushpthread_cleanup_pop必须成对出现在同一个作用域当中,主要是为了约束程序员在清理函数当中行为。下面是在/urs/include/pthread.h文件当中,线程清理函数的定义:

互斥锁

竞争条件

竞争条件

我们来看一下上图的执行过程:

  1. 现在有两个线程在并发执行,左边的线程先将内存当中100拷贝到本线程的寄存器当中

  2. 左边线程在对寄存器的数据做完增加操作的到数据101以后,触发了线程切换

  3. 右边线程也是先将内存的数据100拷贝到本线程的寄存器当中(由于左边线程没有写回内存,所以内存的数据还是100)

  4. 右边线程增加数据再将数据101写回内存

  5. 触发切换,左边线程继续将寄存器的数据101写回内存

上图最后的结果就是,两个线程各自做了一次加1,但是最终的结果当中却没有加2。那么,竞争条件产生的直接原因也就很明显了:当内存设备的数据和寄存器的数据不一致的时候,发生了线程切换。假如我们想要解决竞争条件问题,最直接的解决方案就是控制线程切换的位置,只有在内存和寄存器数据一致的时候才允许发生切换,也就是说我们希望读取-修改-写回这个过程是原子的。

如何才能实现原子性呢?这里有很多种不同层次的解决方案:

目前来说,解决竞争条件问题比较常见且实用的机制就是互斥锁。

互斥锁的基本使用

在多线程编程中,用来控制共享资源的最简单有效也是最广泛使用的机制就是mutex(MUTual EXclusion),即互斥锁

互斥锁的数据类型是pthread_mutex_t,其本质是一个共享的标志位,它存在两种状态:未锁和已锁。在此基础上,互斥锁支持两个原语(原语是不可分割的操作的意思):

  1. 原子地测试并改为已锁状态,即所谓的加锁。当一个线程持有锁的时候,其余线程再尝试加锁时(包括自己再次加锁),会使自己陷入阻塞状态,直到锁被持有线程解锁才能恢复运行。所以锁在某个时刻永远不能被两个线程同时持有。

  2. 解锁,将锁的状态从已锁改成未锁。

创建锁并且让其处于未锁状态的方法有两种:

使用pthread_mutex_lock可以加锁,使用pthread_mutex_unlock可以解锁。使用pthread_mutex_destory可以销毁一个锁。

下面是一个使用锁来保护共享资源的例子。使用锁的原则有这样几个:

加锁和解锁的性能消耗

加锁和解锁的性能消耗

根据上图可以得出结论:加锁/解锁的时间开销只比访问寄存器和L1缓存大,比访问内存、磁盘和网络都要小,因此在绝大多数应用程序当中,加锁解锁的次数对于程序性能没有影响。相反地,使用互斥锁的性能风险,一般来源于临界区的长度——当某个线程的临界区太长的时候,该线程会一直持有锁,导致其他需要使用共享资源的线程均阻塞,影响了整个应用程序的性能。

非阻塞加锁

pthread_mutex_trylock函数可以用来非阻塞地加锁——假如互斥锁处于未锁状态,则加锁成功;假如互斥锁处于已锁状态,函数会直接报错并返回。

死锁 锁的属性

使用互斥锁的时候必须小心谨慎,尽量按照一定的规范使用。如果使用不当的话,很有可能会出现死锁问题。死锁是指一个或多个线程因争夺资源陷入无限等待的状态。例如,线程A持有锁L1并请求锁L2,而线程B持有锁L2并请求锁L1,双方都无法释放已持有的锁,导致程序停滞。

死锁的四个必要条件

常见的死锁场景有这样几种:

  1. 双锁交叉请求:线程t1先加锁M1,再加锁M2;线程t2先加锁M2,再加锁M1;如果加锁时机不对,t1锁上M1之后t2也锁上了M2,此时会触发永久等待;

  2. 持有锁的线程终止:线程t1先加锁M,然后终止,线程t2随后加锁M,将会陷入永久等待;

  3. 持有锁的线程重复加锁:线程t1先加锁M,然后t1再加锁M,此时会永久等待。

为了一定程度上避免第三种死锁场景,用户可以使用pthread_mutexattr_settype函数修改锁属性:

互斥锁应该是最常见的线程同步工具,它的作用是保护共享资源,确保同一时间只有一个线程可以访问该资源。当一个线程获得了互斥锁之后,其他试图获取该锁的线程会被阻塞,直到锁被释放。互斥锁适用于那些需要独占访问资源的场景,比如修改全局变量或者操作数据结构的时候。

读写锁应该允许多个线程同时读取某个资源,但在写入时需要独占访问。也就是说,当没有写线程时,多个读线程可以同时获取锁;但一旦有写线程请求锁,后续的读线程会被阻塞,直到写线程完成操作。读写锁适用于读操作频繁而写操作较少的场景,这样可以提高并发性能。

自旋锁和互斥锁类似,都是用于保护临界区,但自旋锁在获取锁失败时不会让线程进入睡眠状态,而是会一直循环(自旋)检查锁是否被释放。这在多核系统中可能更高效,因为避免了线程切换的开销,但如果锁被长时间持有,会导致CPU资源的浪费。自旋锁适用于临界区很小且持有时间很短的场景,比如内核中的某些操作。

同步和条件变量

利用互斥锁实现同步

同步是指通过协调多个执行单元的行为,确保它们按照预期的逻辑顺序执行。理论上来说,利用互斥锁可以解决同步问题的:

利用互斥锁实现同步

根据上图我们可知,无论t1线程和t2线程按照什么样的顺序交织执行,总是能够满足A先B后的要求。这样,我们就利用互斥锁来实现了同步机制。但是这种只依赖于互斥锁的方案也会存在问题,当t2线程占用CPU但是A事件并没有完成的情况下,t2会执行大量的重复的“加锁-检查条件不满足-解锁”的操作,也就是说不满足条件的线程会一致占用CPU,非常浪费资源。这种CPU资源的浪费的根本原因是竞争——即使本线程不具备继续运行的条件,也要时刻处于就绪状态抢占CPU。

条件变量

对于依赖于共享资源这种条件来控制线程之间的同步的问题,我们希望采用一种无竞争的方式让多个线程在共享资源处汇合——这就是条件变量要完成的目标。

条件变量提供了两个基本的原语:等待唤醒。执行后事件的线程运行时可以等待,执行先事件的线程在做完事件之后可以唤醒其他线程。条件变量的使用是有一定的规范的:在使用条件变量的时候,由于CPU的分配顺序是随机的,所以可能会出现这样的情况——一个线程唤醒时另一个线程还没有进入等待,为了规避无限等待的场景,用户需要根据业务需求去设计一个状态/条件(一般是一个标志位)来表示事件是否已经完成的,这个状态是多线程共享的,故修改的时候必须加锁访问,这就意味着条件变量一定要配合锁来使用

接下来我们来举一个条件变量的例子:

业务场景:假设有两个线程t1和t2并发运行,t1会执行A事件,t2会执行B事件,现在业务要求,无论t1或t2哪个线程先占用CPU,总是需要满足A先B后的同步顺序。

解决方案:

条件变量

通过上面的设计,我们可以保证:无论t1和t2按照什么样的顺序交织执行,A事件总是先完成,B事件总是后完成——即使是比较极端的情况也是如此:比如t1一直占用CPU直到结束,那么t2占用CPU时,状态一定是B可执行,则不会陷入等待;又比如t2先一直占用CPU,t2检查状态时会发现状态为B不可执行,就会阻塞在条件变量之上,这样就要等到A执行完成以后,才能醒来继续运行。

条件变量相关的库函数

pthread_cond_wait的实现原理

pthread_cond_wait的执行流程如下:

进入阻塞之前:

  1. 检查互斥锁是否存在且处于已锁状态;

  2. 将本线程加入到唤醒队列当中;

  3. 解锁并将本线程陷入阻塞态;

在醒来之后(比如被pthread_cond_signal了)

根据上述过程,我们会发现pthread_cond_wait在调用之前是已锁的,返回之后也是已锁的,在等待期间是未锁的。这样设计的原因如下:

  1. pthread_cond_wait一般会配合状态使用,并且需要在if条件下执行,所以我们需要保证无论是否调用pthread_cond_wait,离开if结构之后都是已锁状态;

  2. pthread_cond_wait等待期间其他线程应该有能力修改状态,所以将锁解开。

下面是更加复杂的卖火车票的例子,我们除了拥有两个卖票窗口之外,还会有一个放票部门。

有些情况下,我们需要让因为不同的原因而陷入等待的线程等待在同一个条件变量下,这样的话,只要任何有意思的事件完成,我们就可以通过广播的方式来唤醒所有的线程,再让有条件执行的线程继续运行。

下面是这样的例子:在食堂当中存在一个窗口,同时贩卖两种食材黄焖鸡和猪脚饭,所有的学生只能排在一个队列里面。每个学生只能选择一种食物,食堂窗口每一次也只能完成一份食物。我们需要设计一个合理的流程,来确保每个学生都在自己想要的食物到来以后离开队列。

线程的属性

在线程创建的时候,用户可以给线程指定一些属性,用来控制线程的调度情况、CPU绑定情况、屏障、线程调用栈和线程分离等属性。这些属性可以通过一个pthread_attr_t类型的变量来控制,可以使用pthread_attr_set系列设置属性,然后可以传入pthread_create函数,从控制新建线程的属性。

在这里,我们以pthread_attr_setdetachstate为例子演示如何设置线程的属性。

分离属性影响一个线程的终止状态是否能被其他线程使用pthread_join函数捕获终止状态。如果一个线程设置了分离属性,那么另一个线程使用pthread_join时会返回一个报错。

线程安全

由于多线程之间是共享同一个进程地址空间,所以多线程在访问共享数据的时候会出现竞争问题,这个问题不只会发生在用户自定义函数中,在一些库函数执行中也可能会出现竞争问题。有些库函数在设计的时候会申请额外的内存,或者会在静态区域分配数据结构——一个典型的库函数就是ctimectime函数会把日历时间字符串存储在静态区域。

在上述例子中的,p指向的区域是静态的,所以即使子线程没有作任何修改,但是因为主线程会调用ctime修改静态区域的字符串,子线程两次输出的结构会有不同。使用ctime_r可以避免这个问题,ctime_r函数会增加一个额外指针参数,这个指针可以指向一个线程私有的数据,比如函数栈帧内,从而避免发生竞争问题。

类似于ctime_r这种函数是线程安全的,如果额外数据是分配在线程私有区域的情况下,在多线程的情况下并发地使用这些库函数是不会出现并发问题的。在帮助手册中,库函数作者会说明线程的安全属性。