一、线程概念
1、什么是线程
上面是一张用户级页表,我们都知道可执行程序在磁盘中无非就是代码或数据,更准确点表述,代码也是一种数据,程序一运行,实际就会将其加载到物理内存,因为每个进程都有地址空间和页表,所以可以通过用户页表映射物理内存的方式,来找到磁盘或内存中的数据。
如果想创建一个进程,那么这个进程也应该有自己独立的 task_struct、mm_struct、用户页表。如果创建 “进程”,而不独立创建 mm_struct、用户页表,也不进行 I/O 将程序的代码和数据加载到内存,只创建 task_struct,然后让新的 PCB 指向和老的 PCB 指向同样的 mm_struct,再把代码分成多份,通过当前进程的资源的合理的分配,让 CPU 执行不同 PCB 时访问的是不同的代码,都能使用进程的一部分资源(在系统层面上这里一定可以做到把不同的代码分配给不同的执行流,只要划分用户级页表就可以让不同的 PCB 看到页表的一部分,此时就只能看到进程资源的一部分)。站在资源的角度来说,地址空间其实是进程 pcb 的资源窗口,之前是只有一个窗户和一个人,现在是有多个窗户和多个人。每个 PCB 被 CPU 调度时,执行的粒度一定是要比原始进程执行的粒度要更小,那么我们就称比进程执行粒度更小的为 Linux 线程,这是 Linux 线程的原理。
什么叫做线程?如何理解线程是在进程的内部运行?什么叫做线程是进程的一个执行分支?
什么是进程呢?
也就是说一个进程被创建好后,后续内存可能存在多个执行流,即多线程。现在再站在数据结构角度上看,还要明确进程就是 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。
(2)小结
2、线程的优点
3、线程的缺点
因为所有 PCB 共享地址空间,在理论上,每个线程都能访问进程的所有资源,所以这里还有一个好处就是线程间通信的成本很低,同样缺点也很明显,其内部一定存在着临界资源,所以可能需要使用各种同步与互斥机制。当然线程并不是越多越好,而是合适就好,如果线程增加的太多,可能 大部分时间 CPU 并不是在计算,而是在进行线程切换。就好比一家公司,每个人都各自清楚的做着事情,但当公司人数过多,可能反而会导致公司效率变低。
(1)性能损失
(2)健壮性降低
(3)缺乏访问控制
(4)编程难度提高
4、线程异常
5、线程用途
二、进程 VS 线程
1、进程和线程
- 进程是资源分配的基本单位。
- 线程是调度的基本单位。
- 线程共享进程数据,共享的进程数据包括代码区、字符常量区、全局初始化和未初始化数据、堆区、共享区、命令行参数和环境变量、内核区等等。但也独立拥有自己的一部分数据,这一部分数据是不共享的:
- 线程 ID(即 LWP)
- 对应的寄存器数据(CPU 调度是按 PCB 调度的,每个线程都有自己的上下文数据,所以必须保证每个线程的上下文数据是各自私有的,这也体现了线程是可以切换的)
- 栈(每个线程都有自己的栈结构,这也体现了线程是独立运行的)
- errno
- 信号屏蔽字(多线程中 block 表是私有的)
- 调度优先级
进程的多个线程共享同一地址空间, 因此 Text Segment 、 Data Segment 都是共享的, 如果定义一个函数, 在各线程中都可以调用, 如果定义一个全局变量, 在各线程中都可以访问到, 除此之外 各线程还共享以下进程资源和环境:
- 文件描述符表(注意在多进程中不共享文件描述符表,在管道我们说过两个进程可以指向同一个文件,其中并是说它们共享文件描述符表,而是它们表中的内容是一样的,而多线程是可以共享文件描述符表的)
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL 或者自定义的信号处理函数)(handler 表是线程共享的)
- 当前工作目录
- 用户 id 和组 id
为什么线程调度的成本更低呢?
【进程和线程的关系图 】
如何看待之前学习的单进程?
三、线程控制
1、POSIX线程库
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)接口介绍
(2)错误检查
(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。
现在介绍的线程 ID ,不同于 pthread_t 类型的线程 ID ,和进程 ID 一样,线程 ID 是 pid_t 类型的变量,而且是用来唯一标识线程的一个整型变量。
Linux 提供了 gettid 系统调用来返回其线程 ID,可是 glibc 并没有将该系统调用封装起来,在开放接口来共程序员使用。如果确实需要获得线程 ID,可以采用如下方法:
#include <sys/syscall.h> pid_t tid; tid = syscall(SYS_gettid);
至于线程组其他线程的 ID 则有内核负责分配,其线程组 ID 总是和主线程的线程组 ID 一致,无论是主线程直接创建线程,还是创建出来的线程再次创建线程,都是这样。
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 到底是什么类型呢?
实际上在很多 OS 在设计线程时都是用户级线程,用户级线程就是把相关的属性数据放在用户层,真正的调度还是得由一个相关的执行流来处理的,这叫做 1 : 1,这是 Linux 所采用的。当然在用户层只有一个执行流,但 OS 为了完成你的这个任务,可能会在内核层创建多个执行流去做的,这就叫 1 : N。用户级线程是怎么和内核级线程关联的呢,可以简单的理解成用户级线程只要把代码交给内核级线程代码就可以跑了,创建用户级线程就是创建 LWP,退出用户级线程就是退出 LWP,再把库中的相关数据关掉,只要在用户层的操作可以和内核层对应起来就行了,就像白帮中一名警察派了一个卧底潜伏于黑帮,然后警察指派任务给卧底,警察是可以控制卧底的,警察就是用户级线程,卧底就是内核级线程,它们的关系是 1 : 1 的,内核级进程是与系统是强相关的,如果让用户直接去用它倒也可以,不过用户就要去了解它,成本较高,所以就需要存在用户级进程,让用户更好的使用,同时警察可以站在他的角度向老百姓解释的很清楚,而卧底站在他的角度就解释不清楚。所以 Linux 中要有原生线程库的原因是 Linux 本身没有提供真正意义上的线程,自然也就没有真正意义上的线程控制接口,只能是轻量级进程来模拟,而用户要操作轻量级进程,就得向用户解释更多东西,不是所有人都能理解这种现象的,而用户作为一个东西被偷的人,只需要你把东西找回来就行了,也就是用户仅仅需要知道怎么操作线程就行了。所以需要存在一个用户级线程库才供用户使用,就如同这个世界不是只有老百姓和恶人,而需要有一个警察的角色。
6、线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
(1)pthread_exit 函数
A. 接口介绍
retval:用于传递线程的退出状态,在主线程中,pthread_join() 可以等待新线程结束,并将新线程的退出状态存储在 tret 指针。
B. 代码
(2)pthread_cancel 函数
A. 接口介绍
B. 代码
7、线程等待
为什么需要线程等待?
(1)接口介绍
- thread:线程 ID。
- value_ptr:它指向一个指针,后者指向线程的返回值。
调用该函数的线程将挂起等待, 直到 id 为 thread 的线程终止。 thread 线程以不同的方法终止 , 通过 pthread_join 得到的终止状态是不同的,总结如下:
跟进程一样,一般线程终止后必须进行等待,由 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* 到用户层。
四、分离进程
1、pthread_detach
(1)接口介绍
- thread:输出型参数,代表线程 id。
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
joinable 和分离是冲突的,一个线程不能既是 joinable 又是分离的。
五、线程互斥
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 上提供的这把锁叫做互斥量。
为什么可能无法获得争取结果?
取出 ticket-- 部分的汇编代码,操作并不是原子的,而是对应三条汇编指令:
要解决以上问题,需要做到三点:
要做到这三点,本质上就是需要一把锁,Linux 上提供的这把锁叫互斥量。
3、互斥量的接口
- mutex:初始化或释放的锁。
- attr:锁的属性,设置nullptr,即不管。
(1)初始化互斥量
A. 静态分配(初始化不用 destroy)
B. 动态分配
(2)销毁互斥量
(3)互斥量加锁和解锁
A. 互斥量加锁
B. 互斥量解锁
这里就完成了线程互斥的操作。线程是绝对可以随时被切换的,但是线程是 “拿着锁” 走的,线程不回来,锁没法释放,期间任何线程不在的时候,当然也可以申请锁,只不过不会成功。对其它拥有锁的线程,执行临界区的时候,要么不执行(没有申请锁),要么执行完毕(释放锁),这就间接完成了原子性。此外定义的全局 lock,一定需要被所有线程先看到,所以本质 lock 也是一种临界资源,难道再加一层锁吗?那谁又来保护它呢?所以只需要保证 lock,unlock 时是原子的即可,也就是说在这过程中只会有一个线程对 lock 变量进行操作。
打开加解锁,运行速度明显变慢了,且因为互斥,没有出现原子性问题:
错误写法:
【改进抢票逻辑完整代码】
加锁就是串行执行吗?
加锁之后,线程在临界区中会切换,会有问题吗?
如果线程不申请锁,而只是单纯的访问临界资源呢?
在没有持有锁的线程看来,对它最有意义的两种情况:
- 线程 1 没有持有锁。
- 线程 1 释放锁,此时没有持有锁的线程可以申请锁。
(4)互斥量实现原理探究
经过上面的例子,我们已经能够意识到单纯的 i++ 或者 ++i 都不是原子的,因为这有可能会出现数据不一致问题。为了实现互斥锁操作,大多数体系结构都提供了 swap 或 exchange 指令,该指令的作用是把 CPU 寄存器区和内存单元的数据相交换,由于只有一条指令,就保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。也就是说锁的原子性实现是由寄存器级别的,如下是 lock 和 unlock 的伪代码以及理解:
lock:
movb $0, %al
xchgb %al, mutex
if(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。
(5)可重入 & 线程安全
A. 重入和线程安全的概念
B. 常见的线程不安全的情况
C. 常见的线程安全的情况
D. 常见不可重入的情况
E. 常见可重入的情况
F. 可重入与线程安全联系
G. 可重入与线程安全区别
六、常见锁概念
1、死锁
(1)概念
(2)死锁四个必要条件
只要产生了死锁,一定有如下四个条件:
(3)避免死锁
(4)避免死锁算法
A. 死锁检测算法(了解)
B. 银行家算法(了解)
七、Linux 线程同步
1、条件变量
(1)概念
在抢票时发现票已经售完了,就不应该再申请锁了,而应该等有票了再申请。比如说,中午你问你妈有没有吃的,你妈说没有,过了两秒,你又问你妈有没有吃的,你妈又说没有,反反复复,这样当然没错,只是不合理。实际上你妈告诉你没有吃的的时候,就等同于临界资源中没有票了,而合理的是,你不要着急的去问你妈或申请锁,而应该跟你妈说等会有吃的了叫我一声,然后你就在一旁等待,直到你妈唤醒你,然后你再去询问。所以需要条件变量来描述临界资源状态,之前之所以不断的轮询申请锁、检测、释放锁,本质就是我们并不知道临界资源的状态。
如下图,当右边正在放苹果时,左边的来拿了,此时左侧的人不一定能拿到苹果,因为正在放这个动作包含了放前、放中、放后,所以这就叫做二义性问题。所以使用锁来解决这种问题,无论你要放还是要拿苹果都需要加锁,其过程是原子的,这样就解决了二义性问题。那么问题又来了,拿苹果的比较磨蹭,放苹果的又比较利索,然后放苹果的加锁,再放苹果,接着解锁,假设两个人都是瞎子,那放苹果的也并不知道苹果有没有被拿苹果的拿走,所以放苹果的又开始加锁,然后检测到苹果没有被拿走,接着又释放锁,拿苹果的人实在是太慢了,放苹果的人反反复复加锁、检测、解锁了很多次依旧检测到盒子里的苹果,没有任何有效的动作,放苹果的人的这样一个周期很快,而导致拿苹果的人就算要去拿了也竞争不过放苹果的人。以上帝视角来看,放苹果的人就是在不断的申请释放,而拿苹果的人想拿却竞争不过放苹果的人。反复强调了这种现象当然没有错,只是不合理,所以合理的是放苹果的人加锁、放苹果、解锁、然后敲一下铃铛,就去睡觉了,此时拿苹果的人就知道了(即使拿苹果的人很慢,但他一定可以拿到苹果),一定时间后,拿苹果的人开始加锁、拿苹果、解锁,敲铃铛,也去睡觉了,那么这个时候放苹果的人也知道拿苹果的人把苹果拿走了,放苹果的人就可以继续的往盒子里放苹果了,这里的铃铛被称为条件变量,铃铛就是描述盒子的状态,它对应的就是临界资源,所以条件变量就是描述临界资源的状态。所以,有了条件变量就可以不用频繁的通过申请和释放锁的方式,以达到检测临界资源的目的。
2、同步概念与竞态条件
3、条件变量函数
(1)接口介绍
A. 初始化
- cond:要初始化的条件变量。
- attr:NULL。
B. 销毁
C. 等待条件满足
- cond:要在这个条件变量上等待。
- mutex:互斥量,后面详细解释。
D. 唤醒等待
4、为什么 pthread_ cond_ wait 需要互斥量?
按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码:
// 错误的设计
pthread_mutex_lock(
while (condition_is_false) {
pthread_mutex_unlock(
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(
pthread_mutex_lock(
}
pthread_mutex_unlock(
- 由于解锁和等待不是原子操作,所以在调用解锁之后,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(
while (条件为假)
pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(
(2)给条件发送信号代码
pthread_mutex_lock(
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(