一、线程概念
线程是进程的一个执行分支,是在进程内部运行的一个执行流。下面将从是什么、为什么、怎么办三个角度来解释线程。
1、什么是线程
上面是一张用户级页表,我们都知道可执行程序在磁盘中无非就是代码或数据,更准确点表述,代码也是一种数据,程序一运行,实际就会将其加载到物理内存,因为每个进程都有地址空间和页表,所以可以通过用户页表映射物理内存的方式,来找到磁盘或内存中的数据。
如果想创建一个进程,那么这个进程也应该有自己独立的 task_struct、mm_struct、用户页表。如果创建 “进程”,而不独立创建 mm_struct、用户页表,也不进行 I/O 将程序的代码和数据加载到内存,只创建 task_struct,然后让新的 PCB 指向和老的 PCB 指向同样的 mm_struct,再把代码分成多份,通过当前进程的资源的合理的分配,让 CPU 执行不同 PCB 时访问的是不同的代码,都能使用进程的一部分资源(在系统层面上这里一定可以做到把不同的代码分配给不同的执行流,只要划分用户级页表就可以让不同的 PCB 看到页表的一部分,此时就只能看到进程资源的一部分)。站在资源的角度来说,地址空间其实是进程 pcb 的资源窗口,之前是只有一个窗户和一个人,现在是有多个窗户和多个人。每个 PCB 被 CPU 调度时,执行的粒度一定是要比原始进程执行的粒度要更小,那么我们就称比进程执行粒度更小的为 Linux 线程,这是 Linux 线程的原理。
什么叫做线程?如何理解线程是在进程的内部运行?什么叫做线程是进程的一个执行分支?
什么是进程呢?
站在数据结构角度,仅仅 PCB 已经代表不了进程了,我们要明确进程就是 task_struct + mm_struct + 用户级页表 + 物理内存中映射的代码和数据。
站在 OS 系统角度谈,进程是承担分配系统资源的基本实体。
在创建第 2 个、第 3 个线程时,对应的线程它们所需的资源早就已经申请好了,而这些资源是曾经进程就已经申请好的资源,这就是所谓承担。OS 在分配资源时,不是以一个独立线程去分配,而是先由进程分配资源,然后每个线程再向进程要资源。
举个生活中的例子,你可以向你爸妈要钱,当你不要时,这个钱也早就被你爸妈赚到了,而你爸妈赚的是社会的钱,社会就相当于整个 OS。而你爸妈构建的这一个家庭就是承担分配社会资源的基本实体,基本实体就像一套房,它不是一人一套,而是一个家庭一套。
所以,解决了这个问题,那么前三个问题就迎刃而解了:子女等是在家庭内部运行,线程是在进程的内部运行。子女等是家庭的基本单位,线程是 OS 调度的基本单位。子女等是一个家庭的执行分支,线程是进程的一个执行分支。
也就是说一个进程被创建好后,后续内存可能存在多个执行流,即多线程。现在再站在数据结构角度上看,还要明确进程就是 task_struct + task_struct + task_struct + … … + mm_struct + 用户级页表 + 物理内存中映射的代码和数据,而其中内部的一个执行流只能称为线程。
所以我们再以现在的角度看以前在进程控制、基础 I/O、进程通信中所讲的进程,其实都没有问题,只不过以前讲的进程,其内部只有一个执行流罢了。
(1)Linux 线程 VS 其它平台的线程
前面所谈的本质就是 Linux 线程的基本原理。站在 CPU 的角度,对比历史的进程当然没有区别,CPU 看到的还是一个个的 PCB,只不过 CPU 执行时,“可能” 执行的 “进程流” 已经比历史的更加轻量化了。这很好理解,同一个效率的不同进程,前者只有 1 个执行流,而后者有 4 个执行流,且进行了合理的资源分配。所以当执行后者时,可能就会比前者更加轻量化(注意 5 个 PCB 在 CPU 的等待队列中排队,CPU 在调度时都是按照 1 个 PCB 为单位正常调用,它不关心你前者和后者有几个 PCB)。再者,假设后者两个执行流要进行切换,上下文数据也少不了切换,但 mm_struct、用户页表、代码和数据完全不用管,这相对历史进程切换就显得更加轻量化了。
Linux 下其实并没有真正意义上的线程概念,而是用进程 pcb 模拟的线程。Linux 并不能直接给我们提供线程相关的接口,只能提供轻量级进程的接口。换而言之,我们 Linux 下的进程往往比其它平台上的进程更加轻量化,是因为它有可能是只有一个线程的进程,也有可能是有多个线程的进程,所以我们把 Linux 下的进程称为轻量级进程。所以,站在 Linux 系统的角度,我们不区分它是线程还是进程,而统一轻量级进程。
Windows 具有真正意义上的线程概念。系统中一定存在着大量的进程,而进程 : 线程 = 1 : n,所以系统也一定存在大量的线程,而且不比进程少,OS 也一定要管理线程,那应该如何管理呢?—— 先描述,再组织。所以,支持真线程的系统一定要先描述线程 TCB(Thread Control Block),其内部一定是 PCB && TCB 共生,系统中已经存在了大量的 PCB,还要存在更大量的 TCB,然后 TCB 还要和 PCB 产生某些关系以证明该线程是该进程内的一个执行流,这样一来 OS 既要进程管理,又要线程管理,就一定会使得该 OS 设计的很复杂,其中在描述 TCB 的时候也一定需要和 PCB 类似的各种属性。
但实际上,可以发现线程和进程一样,也是一种执行流,所以一定的是 PCB 和 TCB 在描述时会存在着大量重复的属性。所以,我们可以看到 Windows 确实存在多线程,只不过代价很大,而 Linux 看到后,无论你是什么线程,同样也是执行流,所以就把进程和线程统一了,所以 Linux Kernel 中就没有线程 TCB 的概念。所以,Windows 在 OS 层面下一定提供了相关线程控制的接口,而 Linux 下虽然设计的更简单了,但它不可能在 OS 层面提供线程控制的相关系统调用接口,最多提供了轻量级进程相关的系统调用接口,如 vfork、clone。实际在应用层 Linux 下有一套系统级别的原生线程库 pthread,原生线程库就是在应用层实现的库。其实 C++、Python 等支持多线程的这些语言是有自己原生写好的线程的,且底层一定是用到下面要讲的 pthread。
注意:在编译时需要 -l 在默认路径下指定 pthread 动态库,否则在编译时会报错,在指定 pthread 库后,ldd 就可以看到成功了。
可以看到主线程每 1 秒执行 while 循环,新线程每 1 秒执行回调函数(严格来说,新线程是指主线程调用 pthread_create 时,pthread_create 再去调用 start_routine 的情况)。之前是 ps ajx 来查看一个进程的相关信息,而现在可以用 ps -aL 来查看轻量级进程,可以发现后者的 PID 是相同的,这就说明两个 mythread 本质是属于一个进程,而每一个线程也需要唯一标识。所以,LWP 用来标识线程的唯一性,PID 标识进程的唯一性。这里还可以看到的是第一组 PID 和 LWP 的值是相同的,如果进程内部是单执行流时,此时 ps ajx 和 ps -aL 查看时 PID 和 LWP 的值是一样的,所以在多线程之前 PID 和 LWP 的意义是一样的。所以,调度进程 CPU 看的是 LWP,那么也就说明了如果 PID 和 LWP 两个值是相同的,那么对应的 LWP 对应的 ID 就是主线程。当我们杀死新线程时,也会影响主线程,说明它们是共生的。
(2)小结
- 在一个程序里的一个执行路线就叫做线程(thread),更准确的定义是:线程是 “一个进程内部的控制序列”。
- 一切进程至少都有一个执行线程。
- 线程在进程内部运行,本质是在进程地址空间内运行。
- 在 Linux 系统中,在 CPU 眼中,看到的 PCB 都要比传统的进程更加轻量化。
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
2、线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
- 线程占用的资源要比进程少很多。
- 能充分利用多处理器的可并行数量。
- 在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务。
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
- I/O密集型应用,为了提高性能,将 I/O 操作重叠。线程可以同时等待不同的 I/O 操作。
3、线程的缺点
因为所有 PCB 共享地址空间,在理论上,每个线程都能访问进程的所有资源,所以这里还有一个好处就是线程间通信的成本很低,同样缺点也很明显,其内部一定存在着临界资源,所以可能需要使用各种同步与互斥机制。当然线程并不是越多越好,而是合适就好,如果线程增加的太多,可能 大部分时间 CPU 并不是在计算,而是在进行线程切换。就好比一家公司,每个人都各自清楚的做着事情,但当公司人数过多,可能反而会导致公司效率变低。
(1)性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
(2)健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
(3)缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些 OS 函数会对整个进程造成影响。
(4)编程难度提高
编写与调试一个多线程程序比单线程程序困难得多。
4、线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
5、线程用途
- 合理的使用多线程,能提高 CPU 密集型程序的执行效率。
- 合理的使用多线程,能提高 IO 密集型程序的用户体验。(比如,我们一边写代码一边下载开发工具,就是类似多线程运行的一种表现)
二、进程 VS 线程
1、进程和线程
- 进程是资源分配的基本单位。
- 线程是调度的基本单位。
- 线程共享进程数据,共享的进程数据包括代码区、字符常量区、全局初始化和未初始化数据、堆区、共享区、命令行参数和环境变量、内核区等等。但也独立拥有自己的一部分数据,这一部分数据是不共享的:
- 线程 ID(即 LWP)
- 对应的寄存器数据(CPU 调度是按 PCB 调度的,每个线程都有自己的上下文数据,所以必须保证每个线程的上下文数据是各自私有的,这也体现了线程是可以切换的)
- 栈(每个线程都有自己的栈结构,这也体现了线程是独立运行的)
- errno
- 信号屏蔽字(多线程中 block 表是私有的)
- 调度优先级
- 文件描述符表(注意在多进程中不共享文件描述符表,在管道我们说过两个进程可以指向同一个文件,其中并是说它们共享文件描述符表,而是它们表中的内容是一样的,而多线程是可以共享文件描述符表的)
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL 或者自定义的信号处理函数)(handler 表是线程共享的)
- 当前工作目录
- 用户 id 和组 id
为什么线程调度的成本更低呢?
如果在一个进程内调度其若干个线程,首先地址空间和页表也不需要切换。CPU 内部是有硬件级别 L1~L3 的缓存 Cache 根据局部性原理对内存的代码和数据来预读 CPU 内部。
如果进程切换 cache 就立即失效,新进程过来就只能重新缓存。
【进程和线程的关系图 】
如何看待之前学习的单进程?
具有一个线程执行流的进程。
三、线程控制
1、POSIX线程库
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以 pthread_ 打头的。
- 要使用这些函数库,要通过引入头文 <pthread.h>。
- 链接这些线程函数库时要使用编译器命令的 -lpthread 选项。
- 编译链接时需要 -l 指定 pthread 库。
- 系统中是默认已经安装了 pthread 库。
POSIX 和 System -V 都是用于系统级接口的 IPC(进程间通信)标准,它们可以用于多进程和多线程之间的通信。POSIX 是可移植操作系统接口,由 IEEE 制定的一系列标准,旨在提高 OS 之间的互操作性。而 System 是 AT&T 公司开发的,它是 Unix 的一种版本。相比 System -V,POSIX 是一个比较新的标准,语法也相对简单,而 System -V 年代久远,不过也因此有许多系统支持,使用更加广泛。
由于没有固定标准,所以不同 OS 之间存在一些差异,在不同通信方式中,两者都有利弊。
- 在信号量方面,POSIX 在无竞争条件下不会陷入内核,而 System -V 则是无论何时都要陷入内核,因此后者性能略差。
- 在消息队列方面,POSIX 实现尚未完善,System -V 仍是主流。
在多线程中,基本使用的是 POSIX,而在多进程中则是 System -V。
pthread 是 POSIX 线程库的一部分,它提供了一组 API,用于在多线程环境中创建和管理线程,是一种轻量级进程。pthread 库囊括的东西很多,最经典的是现在所谈的线程库和后面网络所谈的套接字。
2、创建线程
(1)接口介绍
- thread:输出型参数,返回线程 ID。
- attr:设置线程的属性,attr 为 NULL 表示使用默认属性。
- start_routine:想让线程执行的任务,它是一个返回值 void*,参数 void* 的一个函数指针。
- arg:回调函数的参数,若线程创建成功,在执行 start_routine 时,会把 arg 传入 start_routine。
(2)错误检查
- 传统的一些函数是,成功返回 0,失败返回 -1,并且对全局变量 errno 赋值以指示错误。
- pthreads 函数出错时不会设置全局变量 errno(而大部分其他 POSIX 函数会这样做),而是将错误代码通过返回值返回。
- pthreads 同样也提供了线程内的 errno 变量,以支持其它使用 errno 的代码。对于pthreads 函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的 errno 变量的开销更小
(3)代码
【除 0 错误】
这里让新线程执行除 0 操作,我们发现它会影响整个进程。线程是进程的一个执行分支,除 0 错误操作会导致线程退出的同时,也意味着进程触发了该错误,进而导致进程退出。这也就是线程会使用代码健壮性降低的一个表现。
哪个线程先运行跟调度器有关。线程一旦异常,就有可能导致整个进程整体退出。
线程在创建并执行时,线程也是需要进行等待的,如果主进程不等待,就会引起类似于进程的僵尸问题,导致内存泄漏。
主线程可以直接获取新线程退出的结果:
4、进程 ID 和线程 ID
- 在 Linux 中,目前的线程实现是 Native POSIX Thread Libaray,简称 NPTL。在这种实现下,线程又被称为轻量级进程(Light Weighted Process),每一个用户态的线程,在内核中都对应一个调度实体,也拥有自己的进程描述符(task_struct 结构体)。
- 多线程的进程,又被称为线程组,线程组内的每一个线程在内核之中都存在一个进程描述符( task_struct)与之对应。进程描述符结构体中的 pid ,表面上看对应的是进程 ID ,其实不然,它对应的是线程 ID;进程描述符中的 tgid ,含义是 Thread Group ID, 该值对应的是用户层面的进程 ID。
ps 命令中的 -L 选项,会显示如下信息:
- LWP:线程 ID,即 gettid() 系统调用的返回值。
- NLWP:线程组内线程的个数。
Linux 提供了 gettid 系统调用来返回其线程 ID,可是 glibc 并没有将该系统调用封装起来,在开放接口来共程序员使用。如果确实需要获得线程 ID,可以采用如下方法:
#include <sys/syscall.h> pid_t tid; tid = syscall(SYS_gettid);
从上面可以看出,a.out 进程的 ID 为 28543,下面有一个线程的 ID 也 是28543,这不是巧合。线程组内的第一个线程,在用户态被称为主线程(main thread),在内核中被称为 group leader,内核在创建第一个线程时,会将线程组的 ID 的值设置成第一个线程的线程 ID,group_leader 指针则指向自身,既主线程的进程描述符。所以,线程组内存在一个线程 ID 等于进程 ID,而该线程即为线程组的主线程。
// 线程组ID等于线程ID,group_leader指向自身 p->tgid = p->pid; p->group_leader = p; INIT_LIST_HEAD(&p->thread_group);
if (clone_flags & CLONE_THREAD)p->tgid = current->tgid;
if (clone_flags & CLONE_THREAD)
{P->group_lead = current->group_leader;list_add_tail_rcu(&p->thread_group, &p->group_leader->thread_group);
}
5、线程ID及进程地址空间布局和对原生线程库的理解
pthread_t 到底是什么类型呢?
取决于实现,对于 Linux 目前实现的 NPTL 实现而言, pthread_t 类型的线程 ID ,本质就是一个进程地址空间上的一个地址。
- Linux OS 没有真正意义上的线程,而是用进程 PCB 模拟的,这就叫作轻量级进程。其本身没有提供类似线程创建、终止、等待、分离等相关 System Call 接口,但是会提供轻量级进程的接口,如 clone。所以为了更好的适配,系统基于轻量级进程的接口,模拟封装了一个用户层的原生线程库 pthread。这样,系统通过 PCB 来进行管理,用户层也得知道线程 ID、状态、优先级等其它属性用来进行用户级线程管理。
- pthread_create 函数会产生一个线程 ID,存放在第一个参数指向的地址中,该线程 ID 和前面说的线程 ID LWP 不是一回事。前面讲的线程 ID 属于进程调度的范畴,因为线程是轻量级进程,是 OS 调度器的最小单位,所以需要一个数值来唯一表示该线程。pthread_create 函数的第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程 ID,属于 NPTL 线程库的范畴,线程库的后续操作,就是根据该线程 ID 来操作线程。
- 原生线程库是一个库,它在磁盘上就是一个 libpthread.so 文件,运行时加载到内存,然后将这个库映射到共享区,此时这个库就可以被所有线程执行流看到了。此时有两个 ID 概念,一个是在命令行上看到的 LWP,一个是在用户层上看到的 tid。前者是在系统层面上供 OS 调度的,后者是 pthread_create 获得的线程 ID,它是一个用户层概念,本质是一个地址,就是 pthread 库中某一个起始位置,也就是对应到共享区中的某一个位置。所以线程数据的维护全都是在 pthread 线程库中去维护的,上图所示,其中会包含每个线程的局部数据,struct pthread 就是描述线程的 TCB,线程局部存储可以理解是不会在线程栈上保存的数据,我们在上面说过线程会产生各种各样的中间数据,如上下文数据,此时就需要独立的栈去保存,它就是线程栈。而下图中拿到的 tid 就是线程在共享区中线程库内的相关属性的起始地址,所以只要拿到了用户层的 tid,就可以在库中找到线程相关的属性数据,很明显 tid 和 LWP 是 1 : 1 的,而主线程不使用库中的栈结构,直接使用地址空间中的栈区,称为主线程线。
实际上在很多 OS 在设计线程时都是用户级线程,用户级线程就是把相关的属性数据放在用户层,真正的调度还是得由一个相关的执行流来处理的,这叫做 1 : 1,这是 Linux 所采用的。当然在用户层只有一个执行流,但 OS 为了完成你的这个任务,可能会在内核层创建多个执行流去做的,这就叫 1 : N。用户级线程是怎么和内核级线程关联的呢,可以简单的理解成用户级线程只要把代码交给内核级线程代码就可以跑了,创建用户级线程就是创建 LWP,退出用户级线程就是退出 LWP,再把库中的相关数据关掉,只要在用户层的操作可以和内核层对应起来就行了,就像白帮中一名警察派了一个卧底潜伏于黑帮,然后警察指派任务给卧底,警察是可以控制卧底的,警察就是用户级线程,卧底就是内核级线程,它们的关系是 1 : 1 的,内核级进程是与系统是强相关的,如果让用户直接去用它倒也可以,不过用户就要去了解它,成本较高,所以就需要存在用户级进程,让用户更好的使用,同时警察可以站在他的角度向老百姓解释的很清楚,而卧底站在他的角度就解释不清楚。所以 Linux 中要有原生线程库的原因是 Linux 本身没有提供真正意义上的线程,自然也就没有真正意义上的线程控制接口,只能是轻量级进程来模拟,而用户要操作轻量级进程,就得向用户解释更多东西,不是所有人都能理解这种现象的,而用户作为一个东西被偷的人,只需要你把东西找回来就行了,也就是用户仅仅需要知道怎么操作线程就行了。所以需要存在一个用户级线程库才供用户使用,就如同这个世界不是只有老百姓和恶人,而需要有一个警察的角色。
6、线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数 return,这种方法对主线程不适用,从 main 函数 return 相当于调用 exit。
- 线程可以调用 pthread_ exit 终止自己。
- 一个线程可以调用 pthread_ cancel 终止同一进程中的另一个线程。
(1)pthread_exit 函数
A. 接口介绍
线程终止。
retval:用于传递线程的退出状态,在主线程中,pthread_join() 可以等待新线程结束,并将新线程的退出状态存储在 tret 指针。
注意 : pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的或者是用 malloc 分配的, 不能在线程函数的栈上分配, 因为当其它线程得到这个返回指针时线程函数已经退出了。
B. 代码
(2)pthread_cancel 函数
A. 接口介绍
取消一个执行中的线程。
B. 代码
__thread: 修饰全局变量,结果就是让每一个线程各自拥有一个全局变量 —— 线程的局部存储。
7、线程等待
为什么需要线程等待?
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。创建新的线程不会复用刚才退出线程的地址空间。
(1)接口介绍
- thread:线程 ID。
- value_ptr:它指向一个指针,后者指向线程的返回值。
如果 thread 线程通过 return 返回, value_ ptr 所指向的单元里存放的是 thread 线程函数的返回值。 如果 thread 线程被别的线程调用 pthread_ cancel 异常终掉, value_ ptr 所指向的单元里存放的是常数 PTHREAD_CANCELED。 如果 thread 线程是自己调用 pthread_exit 终止的, value_ptr 所指向的单元存放的是传给 pthread_exit 的参数。 如果对 thread 线程的终止状态不感兴趣, 可以传 NULL 给 value_ ptr 参数。
跟进程一样,一般线程终止后必须进行等待,由 main thread 进行等待。因为要防止内存泄漏,资源浪费(通过线程等待,将曾经线程向进程在地址空间中申请的资源释放,在线程这里一般是说如果不释放相关线程,那么申请新线程时不会复用未释放的线程)。保证主线程最后退出,让新线程正常结束。获得新线程的退出码信息(而 pthread_create 时调用 start_routine 新线程传入的参数是 void* 且返回的类型是 void*,这样它就是一个通用接口,也意味着新线程可以返回任意类型的数据,此时 pthread_join 时,就会被 retval 拿到)。实际在底层就是 pthread_create 调用 start_routine 后,start_routine 将线程退出码写到对应 PCB 中,然后调用 pthread_join 时就可以从对应 PCB 中读取退出结果到 retval。
我们都知道不可能通过 fun 函数来把 10 拿出去的原因是因为它是值传递,而应该地址传递。同样,如果想在一个函数内部返回一个 void* 的值也很简单。pthread_create 中 start_routine 参数和返回值类型是 void*,它是要支持通用接口,而 pthread_join 中 retval 的类型是 void**,然后 pthread_join 会通过你传入的线程 id,去读取对应的 PCB 中的退出码信息,因为退出码信息可能是不同类型的地址,所以要用 void** 来接收,retval 是输出型参数,然后又由它返回 void* 到用户层。
四、分离进程
- 默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join 是一种负担,这个时候我们可以告诉系统,当线程退出时,自动释放线程资源。
1、pthread_detach
(1)接口介绍
用于创建线程,它是一个回调函数。如果线程创建成功,则会执行 start_routine,编译和链接时需要引入 pthread。
- thread:输出型参数,代表线程 id。
- 默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成内存泄漏。
- 如果不关心线程的返回值,join 则是一种负担,这个时候,可以使用分离,此时就告诉系统,当线程退出时,自动释放线程资源,这就是线程分离的本质。
- joinable 和 pthread_detach 是冲突的,也就是说默认情况下,新创建的线程是不用 pthread_detach。
- 就算线程被分离了,也还是会和其它线程影响的,因为它们共享同一块地址空间。
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
joinable 和分离是冲突的,一个线程不能既是 joinable 又是分离的。
注意:没有线程替换这种操作,但可以在线程中执行进程替换系列函数。这是因为新线程内部执行进程替换函数,这看起来像是把新线程中的代码替换了,但实际会把主线程中的代码也替换了,因为主线程和新线程共享地址空间,所以新线程内部进程替换后,所有的线程包括主线程都会被影响。所以轻易不要在多线程中执行进程替换函数。
pthread_join 返回的是 22,说明等待失败了,然后返回,进程终止。其实一个线程被设置为分离状态,则该线程不应该被等待,如果被等待了,结果是未定义的,至少一定会等待出错。
五、线程互斥
1、进程线程间的互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源。
临界区 :每个线程内部访问临界资源的代码,就叫做临界区。 互斥 :任何时刻,互斥保证有且只有一个执行流进入临界区访问临界资源,通常对临界资源起保护作用。 原子性 :不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
(1)模拟抢票逻辑
或者也可以说 if 判断条件为真后,代码可能并发切换到其它线程,因为 usleep 漫长的等待过程中可能会有多个线程进入该临界区,所以 tickets 就不是原子的。实际多线程在切换时极有可能出现因为数据交叉操作而导致数据不一致的问题,那么 OS 中可能从内核态返回用户态的时候就会进行线程间切换。
A. 代码
如下图,线程 A 将 tickets:1000 从内存读到 CPU,然后 ticket--。因为某种原因,还没等线程 A 把 tickets 写回内存,就被 CPU 剥离,在剥离之前 CPU 上一定有线程 A 的临时数据或者说是上下文数据,然后将上下文数据保存在线程内部,紧接着 CPU 开始调度线程 B,线程 B 将 tickets:1000 从内存读到 CPU。因为线程 B 的运气比较好,所以 tickets-- 了 500 次,然后因为时间片到了,就把 tickets:500 写回内存,然后 CPU 继续调度线程 A,此时将线程 A 中保存的上下文数据恢复到 CPU 对应的寄存器中,再继续执行第 3 步,把 tickets:999 写回物理内存。
B. 分析
a. 出现原子性问题
当第 1 个线程 if 判断成功后,执行到 usleep,陷入内核休眠并执行第 2 个线程,以此类推。后来第 1 个线程醒来后,输出并执行 tickets--,以此类推。这样就有可能出现多个线程都在 if 判断中,这样就有可能会出现减到负数的情况。要解决这种问题,所以就引出了线程互斥。
b. 未出现原子性问题
2、互斥量 mutex
而要做到这三点,就需要一把锁,Linux 上提供的这把锁叫做互斥量。
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
为什么可能无法获得争取结果?
- if 语句判断条件为真以后,代码可以并发的切换到其他线程。
- usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
- --ticket 操作本身就不是一个原子操作。
取出 ticket-- 部分的汇编代码,操作并不是原子的,而是对应三条汇编指令:
- load,将共享变量 ticket 从内存加载到寄存器中。
- update,更新寄存器里面的值,执行操作。
- store,将新值从寄存器写回共享变量 ticket 的内存地址。
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为,当代码进入临界区执行时,不允许其它线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其它线程进入临界区。
要做到这三点,本质上就是需要一把锁,Linux 上提供的这把锁叫互斥量。
3、互斥量的接口
- mutex:初始化或释放的锁。
- attr:锁的属性,设置nullptr,即不管。
(1)初始化互斥量
A. 静态分配(初始化不用 destroy)
B. 动态分配
(2)销毁互斥量
销毁互斥量需要注意:
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁。
- 不要销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
(3)互斥量加锁和解锁
A. 互斥量加锁
调用 pthread_lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么 pthread_ lock 调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
B. 互斥量解锁
这里就完成了线程互斥的操作。线程是绝对可以随时被切换的,但是线程是 “拿着锁” 走的,线程不回来,锁没法释放,期间任何线程不在的时候,当然也可以申请锁,只不过不会成功。对其它拥有锁的线程,执行临界区的时候,要么不执行(没有申请锁),要么执行完毕(释放锁),这就间接完成了原子性。此外定义的全局 lock,一定需要被所有线程先看到,所以本质 lock 也是一种临界资源,难道再加一层锁吗?那谁又来保护它呢?所以只需要保证 lock,unlock 时是原子的即可,也就是说在这过程中只会有一个线程对 lock 变量进行操作。
打开加解锁,运行速度明显变慢了,且因为互斥,没有出现原子性问题:
错误写法:
注意:这样做是有问题的,因为一个线程已经锁定互斥量,其它线程申请互斥量,但没有竞争到互斥量,那么 pthread_lock 调用时会陷入阻塞,看到的现象就是执行流被挂起,等待直到互斥量解锁。也就是说这里当一个线程执行到 tickets == 0 时,会走到 else,break 出循环,此时加锁却没有解锁,现在会一直处于阻塞状态,其它线程都在等待。所以,正确写法应该是上面运行的代码。
【改进抢票逻辑完整代码】
加锁就是串行执行吗?
是的,执行临界区代码一定是串行的。
加锁之后,线程在临界区中会切换,会有问题吗?
不会有问题。虽然线程被切换了,但是是在持有锁的情况下被切换的,所以其它抢票线程要执行临界区代码,也必须先申请锁,但它是无法申请成功的。所以,也不会让其他线程进入临界区,也就保证了临界区中数据的一致性。
如果线程不申请锁,而只是单纯的访问临界资源呢?
不被允许,这是错误的编码方式。
在没有持有锁的线程看来,对它最有意义的两种情况:
- 线程 1 没有持有锁。
- 线程 1 释放锁,此时没有持有锁的线程可以申请锁。
要访问临界资源,那么每一个线程都必须实现申请锁,每一个线程必须都得看到同一把锁,且去访问它。锁本身就是一种共享资源,那么为了保证锁的安全,申请和释放锁必须都是原子的。
(4)互斥量实现原理探究
经过上面的例子,我们已经能够意识到单纯的 i++ 或者 ++i 都不是原子的,因为这有可能会出现数据不一致问题。为了实现互斥锁操作,大多数体系结构都提供了 swap 或 exchange 指令,该指令的作用是把 CPU 寄存器区和内存单元的数据相交换,由于只有一条指令,就保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。也就是说锁的原子性实现是由寄存器级别的,如下是 lock 和 unlock 的伪代码以及理解:
lock:movb $0, %alxchgb %al, mutexif(a1寄存器的内容 > 0){return 0;}else{挂起等待; } goto lock;
unlock:movb $1, mutex唤醒等待mutex的线程;return 0;
mutex 就是一个内存中的变量,和定义 int 没太大区别,假设 mutex 默认是 1。
线程 X 执行 movb $0,%al 将 %al 寄存器清零。在这个过程中线程 Y 当然也有可能执行这条语句,这没问题,因为线程在剥离时会作上下文数据的保存,线程在切换时就会把上下文数据保存于 TCB 中。
线程 X 执行 xchgb %al,mutex 将寄存器 %al 中的值 0 和内存的值 mutex:1 交换,这意味着 mutex 里面的值被交换到了对应线程中的上下文中,就相当于变成线程私有的。是的,在汇编代码上只要一条语句就完成交换了,因为它只是一条语句,所以交换过程一定是原子的。具体 xchgb 是怎么完成的,可以去了解了解它的汇编原理,简单提一下就是在体系结构上有一个时序的概念,在一个指定周期中,去访问总线时,汇编指令能被 CPU 接收到,是因为汇编指令会在特定的时间放在总线上的,而总线是可以被锁住的,这在硬件上实现比较简单,所以即使 xchgb 汇编翻译成二进制的时候,是对应多条语句的,但是它依旧不影响,因为硬件级别把总线锁住了,所以它虽然在汇编翻译成二进制时依旧是有多条语句的,但是因为总线被锁住了,所以不会出现原子性问题。
当线程 X 交换完后,线程 Y 突然切换进来了,在此之前就会把线程 X 的上下文数据保存于线程 X 的 TCB 中,然后把线程 Y 中上一次在 TCB 中保存的上下文数据恢复到 CPU(这里没有),然后线程 Y 执行 movb $0,%al 将 %al 寄存器清零,执行 xchgb %al,mutex 将寄存器 %al 中的值 0 和内存的值 mutex:0 交换。所以线程 Y 再往下走就会 else 挂起等待。
再线程切换到线程 X,把线程 Y 中的上下文数据保存于自己的 TCB,然后线程 X 把自己在 TCB 中保存的上下文数据数据恢复到 CPU,再执行 if,就可以访问临界区中的代码了,然后访问临界区成功,并返回。
然后线程 X 去执行 unlock 中的 movb $1,mutex 把内存中的 mutex 值又置为 1,然后唤醒等待 mutex 的线程 Y,成功返回,这样线程 X 就完成了解锁,unlock 也一定是原子的,因为能执行 unlock 的一定是曾经 lock 过的,所以 unlock 是不是原子性的已经不是重点了。最后切换到线程 Y,将上下文数据恢复,继续往下执行 goto lock,将寄存器 %al 清零, 然后将其与内存中的 mutex 值交换,成功访问临界区资源返回,然后把 mutex 值置为 1,没有唤醒的 mutex 线程,然后成功解锁。注意这里以不用管寄存器中的 %al:1,因为下次在申请锁时会先把 %al 置 0。
注意:
- 这里的 %al 是寄存器数据,是线程的上下文数据,代表的是线程私有。
- mutex 互斥锁,本质就是内存中的一个内存空间,空间里面的值是 1,是可以被所有的线程读取并访问的。
- 综上,在交换的时候能保证只有一个 1。为什么一定要 swap/exchange 或者 xchgb 呢,mov %al, mutex 不行吗?一定不行,虽然 mov 也是一条汇编,但是它是拷贝,和交换是不一样的,拷贝会把 mutex 的值拷贝到寄存器中,这样一来,就不能保证只有一个 1 了,其它线程依然可以在一个线程正在访问临界区时也访问临界区,所以需要 swap/exchange 或者 xchgb 指令,以保证在多线程执行时,每个线程的上下文中,只有一个 1,这样就可以保证了原子了。
(5)可重入 & 线程安全
A. 重入和线程安全的概念
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,就称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则就是不可重入函数。
线程安全 :多个线程并发同一段代码时不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下会出现该问题。
B. 常见的线程不安全的情况
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
C. 常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
D. 常见不可重入的情况
- 调用了 malloc/free 函数,因为 malloc 函数是用全局链表来管理堆的。
- 调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
E. 常见可重入的情况
- 不使用全局变量或静态变量。
- 不使用用 malloc 或者 new 开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都有函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
F. 可重入与线程安全联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
G. 可重入与线程安全区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
六、常见锁概念
1、死锁
(1)概念
死锁是一种常见的锁,死锁是指在一组进程或线程中的各个进程或线程均占有不会释放的资源,它们申请锁而不释放锁,进而导致多个执行流互相等待的状态。
死锁通常发生在两个或多个进程或线程之间竞争资源的情况,当一个进程或线程持有一个资源并请求另一个进程或线程持有的资源时,如果另一个进程或线程也持有该进程所需的资源并请求第一个进程或线程的资源,就有可能发生死锁。
举个例子,小明和小红身上各有 5 毛钱,想买 1 块钱的辣条,小明对小红说把你的 5 毛钱给我,我来买,小红也对小明说把你的 5 毛钱给我,我来买,此时他们既想要申请对方的 5 毛钱,又不想妥协释放自己的 5 毛钱,这种状态就叫做死锁。
(2)死锁四个必要条件
只要产生了死锁,一定有如下四个条件:
- 互斥条件:一个资源每次只能被一个执行流使用。(本质上引入互斥就是为了保护临界资源,由并行变成了串行,保证了原子性。但也带了新的问题:死锁。因为互斥只要存在就会产生申请资源阻塞的情况,就有可能出现死锁)
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。(其实就是请求你的锁给我,我的锁不释放)
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。(一般而言,你要我的资源,而我要别人的资源,是不会造成死锁的。而你要我的资源,我也要你的资源,就可能会造成死锁,因为我们是竞争关系)
(3)避免死锁
- 破坏死锁的四个必要条件其中一个。(互斥条件很难被破坏,因为它要求一次只有一个进程或线程使用资源,一般也建议能不用锁就不用锁;请求与保持就是释放相对应的锁资源;不剥夺条件就是可以根据优先级设计一个算法,就算一个执行流未使用完资源,也可以被剥夺;循环等待条件就是若干执行流之间在申请和释放锁资源时不要形成回路)
- 加锁顺序一致。
- 避免锁未释放的场景。
- 资源一次性分配。
(4)避免死锁算法
A. 死锁检测算法(了解)
死锁检测算法是一种利用进程状态的变化来识别死锁的方式,能够提高系统的并发性和效率。
这种算法主要有两种形式:
- 预防式,它在进程申请资源时就进行干预,以防止系统进入不安全状态;
- 事后处理,它允许系统进入不安全状态,然后通过某种算法检测并恢复。
具体来说,死锁检测需要用某种数据结构来保存资源的请求和分配信息,并提供一种算法,利用上述信息来检测系统是否已进入死锁状态。如果检测到系统已经发生死锁,可以采取相应的措施解除死锁,如资源剥夺法、撤销进程法或进程回退法等。值得注意的是,死锁检测通常在资源有向图(也称为资源分配图)上进行。
因此,理解和掌握如何有效地在资源有向图上进行死锁检测和解除,对于提高系统的性能和稳定性至关重要。
B. 银行家算法(了解)
银行家算法,是由艾兹格·迪杰斯特拉在 1965 年为 T.H.E 系统设计的一种避免死锁产生的算法。这个算法不仅适用于操作系统给进程分配资源,还可以用于其他需要进行资源分配和调度的场景。
该算法主要使用了四个数据结构:可利用资源向量 Available、最大需求矩阵 Max、分配矩阵 Allocation 以及需求矩阵 Need。这些数据结构分别记录了系统中当前可用的资源数量、每个进程对资源的最大需求量、系统已经分配给每个进程的资源数量以及每个进程还需要的资源数量。
在实现的过程中,银行家算法首先检查系统是否处于安全状态,如果是,则允许进程继续申请资源;否则,将拒绝进程的请求。如果系统进入不安全状态,则需要采取相应的措施使系统返回到安全状态。
总的来说,银行家算法是一种既简单又有效的死锁避免方法,它通过合理的资源分配和调度来防止系统进入不安全状态,从而避免了死锁的发生。
七、Linux 线程同步
很明显,线程同步和线程互斥是相对的。对于前面的模拟抢票逻辑的实现,当然可能存在某个线程竞争锁的能力特别强,每次申请都是它优先申请到锁资源,其它线程没有机会拥有锁,我们称这种问题为饥饿问题,这样就会出现该线程一直在循环进行申请锁、检测(抢票)、释放锁,即使票已经没有了,因为释放锁后并没有 break 出循环。
饥饿问题本身并没有错,只是说不合理。就好比如你从墙边拿了钥匙去单间自习室中自习,一会出来了,刚把钥匙挂在墙边,看到有很多人在排队,你想着好不容易抢到了单间自习室,于是你刚放下钥匙又拿起了钥匙进去自习,一会又出来放钥匙了,看到人还是很多,心想把明早的也自习算了,于是又拿着钥匙去自习了,反反复复。你本质能反反复复的进去自习是因为你离钥匙最近,理论上虽然没问题,但却不合理。所以你作为自习室的管理者,就规定了如果从单间自习室出来,把钥匙挂在墙上后,不能立即再申请钥匙,如果你要再次申请,就必须排到队列的尾部。
排队的本质就是让我们获取锁在安全的前提下,按照某种顺序进行申请和释放,这就叫做线程同步的过程。同步不是严格意义上解决数据正确或错误的问题,而是解决不合理的问题。所以我们经常会一起听到互斥和同步这两个概念,因为光有互斥还无法保证执行流进行合理的临界资源访问。
1、条件变量
(1)概念
条件变量是一个原生线程库提供的一个描述临界资源状态的对象或变量。
在抢票时发现票已经售完了,就不应该再申请锁了,而应该等有票了再申请。比如说,中午你问你妈有没有吃的,你妈说没有,过了两秒,你又问你妈有没有吃的,你妈又说没有,反反复复,这样当然没错,只是不合理。实际上你妈告诉你没有吃的的时候,就等同于临界资源中没有票了,而合理的是,你不要着急的去问你妈或申请锁,而应该跟你妈说等会有吃的了叫我一声,然后你就在一旁等待,直到你妈唤醒你,然后你再去询问。所以需要条件变量来描述临界资源状态,之前之所以不断的轮询申请锁、检测、释放锁,本质就是我们并不知道临界资源的状态。
如下图,当右边正在放苹果时,左边的来拿了,此时左侧的人不一定能拿到苹果,因为正在放这个动作包含了放前、放中、放后,所以这就叫做二义性问题。所以使用锁来解决这种问题,无论你要放还是要拿苹果都需要加锁,其过程是原子的,这样就解决了二义性问题。那么问题又来了,拿苹果的比较磨蹭,放苹果的又比较利索,然后放苹果的加锁,再放苹果,接着解锁,假设两个人都是瞎子,那放苹果的也并不知道苹果有没有被拿苹果的拿走,所以放苹果的又开始加锁,然后检测到苹果没有被拿走,接着又释放锁,拿苹果的人实在是太慢了,放苹果的人反反复复加锁、检测、解锁了很多次依旧检测到盒子里的苹果,没有任何有效的动作,放苹果的人的这样一个周期很快,而导致拿苹果的人就算要去拿了也竞争不过放苹果的人。以上帝视角来看,放苹果的人就是在不断的申请释放,而拿苹果的人想拿却竞争不过放苹果的人。反复强调了这种现象当然没有错,只是不合理,所以合理的是放苹果的人加锁、放苹果、解锁、然后敲一下铃铛,就去睡觉了,此时拿苹果的人就知道了(即使拿苹果的人很慢,但他一定可以拿到苹果),一定时间后,拿苹果的人开始加锁、拿苹果、解锁,敲铃铛,也去睡觉了,那么这个时候放苹果的人也知道拿苹果的人把苹果拿走了,放苹果的人就可以继续的往盒子里放苹果了,这里的铃铛被称为条件变量,铃铛就是描述盒子的状态,它对应的就是临界资源,所以条件变量就是描述临界资源的状态。所以,有了条件变量就可以不用频繁的通过申请和释放锁的方式,以达到检测临界资源的目的。
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
2、同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。
3、条件变量函数
(1)接口介绍
A. 初始化
初始化 cond,并设置属性 attr。
- cond:要初始化的条件变量。
- attr:NULL。
B. 销毁
销毁条件 cond。
C. 等待条件满足
- timedwait:指定的时间范围内等待条件变量下的线程(少用)。
- wait:指定的条件变量下进行等待,一定要入 cond 的队列,mutex 用于在锁中阻塞挂起时会自动释放锁,唤醒时会自动获取锁(后面生产者消费者中详细介绍)。
- cond:要在这个条件变量上等待。
- mutex:互斥量,后面详细解释。
D. 唤醒等待
- signal:唤醒指定条件变量下等待的一个线程。
- broadcast:唤醒所有在该条件变量下等待的线程(少用)。
4、为什么 pthread_ cond_ wait 需要互斥量?
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码:
// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {pthread_mutex_unlock(&mutex);//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过pthread_cond_wait(&cond);pthread_mutex_lock(&mutex);}
pthread_mutex_unlock(&mutex);
- 由于解锁和等待不是原子操作,所以在调用解锁之后,pthread_ cond_ wait 之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_ cond_ wait 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_ cond_ wait,所以解锁和等待必须是一个原子操作。
- int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后,会去看条件量是否等于 0。如果等于,就把互斥量变成 1,直到 cond_ wait 返回,把条件量改成1,把互斥量恢复成原样。
5、条件变量使用规范
(1)等待条件代码
pthread_mutex_lock(&mutex);
while (条件为假)pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
(2)给条件发送信号代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);