文章目录
一、线程概念
1.基本认识
在书上,线程的定义一般是:是在进程内部运行的一个执行分支(执行流),属于进程的一部分,粒度要比进程更加细和轻量化。
一个进程内可能存在多个线程,所以 OS 需要管理线程。
那么 OS 如何管理呢?先描述再组织。
系统内也应该要有描述线程的数据类型,是 TCB(线程控制块),struct tcb { //线程的所有属性 }; 。不过,这是常规操作系统的做法(比如 Windows)。
但是 Linux 对线程的实现是不一样的。
Linux 没有专门为线程设计 TCB ,而是复用进程的 PCB 来模拟线程(更准确地说,是用进程 PCB 来充当 TCB),这样的好处是不用专门单独为线程设计 TCB ,就不用维护复杂的进程和线程的关系,不用单独为线程设计任何的算法,直接复用进程的一套相关的方法。OS 只需要聚焦在线程间的资源分配上就可以了。
只创建 PCB ,共享同一个地址空间,当前进程的资源(代码+数据),划分为若干份,分配给每个 PCB 使用。
线程在进程内部运行,本质是在进程地址空间内运行。
从 CPU 的角度看来,它实际在调度的时候,只调度一个 PCB ,可能这个 PCB 属于一个进程的其中一个,也可能是另一个进程的其中一个。
CPU 认为一个 PCB 就是一个需要被调度的执行流。此时这样的一个 PCB 在 Linux 中称之为线程。
由于 Linux 复用进程来模拟线程,所以 Linux 的健壮性非常地强。
重新认识线程的概念:
线程:是在进程内部运行的一个执行分支(执行流),属于进程的一部分,粒度要比进程更加细和轻量化。
① 内部:线程在进程地址空间内运行。
② 执行分支(执行流):CPU 在调度的时候只看 PCB ,每一个 PCB 曾经被指派过执行方法和数据,CPU 可以直接地调度。
③ 属于进程的一部分:线程是进程的其中一个执行流,属于进程的一部分。
2.进程和线程
创建进程需要创建 PCB、地址空间、页表,加载数据和代码,构建映射关系,维护进程文件、进程信号等各种关系,而创建线程不用创建地址空间和页表,也不用维护映射关系,只需要创建 PCB ,把进程的资源分配给线程就可以。
很明显,创建进程的成本(时间 + 空间)非常高,要使用的资源是非常多的,因为它是从 0 到 1 的。
内核视角:
① 进程是承担分配系统资源的基本实体。
② 线程是 CPU 调度的基本单位,承担进程的一部分资源的基本实体。
进程的内部可以具有多个执行流,一个执行流称之为一个线程。
3.轻量级进程
Linux 的 PCB 要比传统意义上的进程 PCB 更轻量化一些,主要有以下两方面:
① OS 创建线程更轻量化。
② CPU 调度更轻量化。
所以,Linux 进程,称之为轻量级进程(LWP,Light Weight Process)。
由于 Linux 是用进程来模拟线程的,所以实际上,Linux 没有真正意义上的线程(thread),而是叫轻量级进程(LWP)。
所有的轻量级进程都是在进程的内部(地址空间)运行的,而地址空间表示进程所能看到的大部分资源,所以,理论上,所有的线程都可以看到任何一个线程的所有资源,因为共享地址空间。
4.线程对资源的共享和私有
(1)共享
进程的多个线程共享同一个地址空间,因此代码段和数据段都是共享的。
如果定义一个函数,在各线程中都可以调用。
如果定义一个全局变量,在各线程中都可以访问到,
除此之外,各线程还共享以下进程资源和环境:
① 文件描述符表。
② 每种信号的处理方式。
③ 当前工作目录。
④ 用户 id 和组 id 。
(2)私有
线程共享大部分资源,但也拥有自己的一部分数据:
① 线程 ID
② 一组寄存器
③ 栈
④ errno
⑤ 信号屏蔽字
⑥ 调度优先级
线程是调度的基本单位,所以它一定会形成自己在 CPU 寄存器中的临时数据,因此它必须拥有独立的上下文。
线程和线程间的临时数据不会互相干扰。
5.线程的优点、缺点和异常
(1)线程的优点:
① 创建一个新线程的代价要比创建一个新进程小得多。
② 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
③ 线程占用的资源要比进程少很多。
④ 能充分利用多处理器的可并行数量。
⑤ 在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务。
⑥ 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
⑦ I/O 密集型应用,为了提高性能,将等待 I/O 时间重叠。线程可以同时等待不同的 I/O 操作。
(2)线程的缺点:
① 性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
② 健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
③ 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些 OS 函数会对整个进程造成影响。
④ 编程难度提高:编写与调试一个多线程程序比单线程程序困难得多。
(3)线程异常:
线程是进程的执行分支,线程出异常,意味着进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
线程崩溃的影响一定是有限的,因为线程在进程内部,而进程具有独立性!
6.线程用途
① 合理地使用多线程,能提高 CPU 密集型程序的执行效率。
② 合理地使用多线程,能提高 IO 密集型程序的用户体验(比如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。
7.进程与线程的四种关系
二、线程控制
Linux 因为是用进程来模拟线程的,所以不会给我们提供直接操作线程的接口,而是给我们提供,在同一个地址空间内创建 PCB 的方法,分配资源给指定 PCB 的接口,但这对用户特别不友好!
于是一些系统工程师在用户层对 Linux 轻量级进程接口进行封装,给我们打包成库,让用户直接使用库接口。这个库叫做原生线程库,它在用户层。
POSIX线程库:
① 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以 “pthread_” 打头的。
② 要使用这些函数库,需要引入头文件 <pthread.h> 。
③ 链接这些线程函数库时要使用编译器命令的 “-lpthread” 选项。
1.线程创建
pthread_create 函数:
pthread_create
函数的作用:在当前进程中创建一个新的线程。
pthread_create
函数的参数:
① thread 是输出型参数,当创建新线程成功时,带回新线程 id 。
② attr 设置新线程的属性,若 attr 为 NULL ,表示使用默认属性。
③ start_routine 是一个函数指针,表示新线程启动后要执行的函数。
④ arg 作为 start_routine 函数的唯一参数被传递给 start_routine 函数。
pthread_create
函数的返回值:
① 成功,返回 0 。
② 错误,返回一个错误码。
示例一:
主线程调用pthread_create
函数成功后,继续向下执行。新线程执行 thread_run 函数。
注:编译和链接时要引入 pthread 这个库。
运行结果:
说明此时依旧是只有一个进程,但是进程内部具有两个执行流(线程的 PCB 中保存的 pid 是一样的,证明它们属于同一个进程;LWP 不一样,证明它们是不同的执行流)。
我们给这个进程发送终止信号,进程退出了,在其内部的所有线程也就都退出了。
注:我们可以使用ps -aL
命令来查看轻量级进程 LWP 的信息。
Linux 的 OS 在调度的时候,看的是 LWP 。
PID == LWP 的线程,表明它是当前进程的主线程。
一般把属于同一个进程的一批线程叫做一个线程组。线程组的组 id 即当前进程的 pid 。
示例二:
线程可以通过pthread_self
函数来获取自身的 ID 。
我们可以看到打印出来的线程 ID 的值很大,但是为什么和 LWP 不一样呢?
我们在下面会详细说明。
创建多个线程:
一个线程崩溃,整个进程都会崩溃,其它线程也都会退出:
2.线程等待
一般而言,线程也是需要被等待的,如果不等待,可能会导致类似于僵尸进程的问题。
主线程创建一个新线程,目的是让它执行任务。若主线程关心新线程的任务执行结果,那么主线程需要等待新线程退出。
pthread_join 函数:
pthread_join
函数的作用:阻塞式等待线程终止。
pthread_join
函数的参数:
① thread 指定等待某个线程的 ID 。
② retval 是输出型参数,用来获取线程的退出状态。
pthread_join
函数的返回值:
① 成功,返回 0 。
② 错误,返回一个错误码。
① 主线程阻塞式等待新线程,直到新线程退出才能 join 成功,拿到新线程的执行函数的返回值。
② 循环式地阻塞等待多个线程退出。
线程退出,跟进程退出类似,无非三种情况:
① 代码跑完,结果对。
② 代码跑完,结果不对。
③ 代码异常。
我们通过pthread_join
函数能够获取线程的退出信息(能够处理前两种情况),那异常呢?
若线程异常,意味着该进程异常导致收到信号退出,由其父进程来获取该进程的退出信号相关信息。
3.线程终止
线程终止的方案:
(1) 在线程函数中 return:
① 在 main 函数中 return ,代表进程退出。
② 在其它线程函数中 return ,只代表当前线程退出。
(2) 线程通过pthread_exit
函数终止自己。
pthread_exit 函数:
pthread_exit
函数的作用:终止调用线程。
pthread_exit
函数的参数:
① retval 指向返回值(可以被当前进程内调用了 pthread_join 函数的其它线程获得),不要指向一个局部变量。
需要注意的是,pthread_exit
或者return
返回的指针所指向的内存单元必须是全局的或者是用 malloc 分配的,不能在线程函数的栈上,因为当其它线程得到这个返回指针时线程函数已经退出了。
pthread_exit
函数的返回值:不会返回给调用者。
pthread_exit
函数示例:
注意:exit
函数的作用和pthread_exit
函数不一样!exit
函数的作用是终止进程,不要在其它线程中调用它,如果你就想终止一个线程的话。
线程调用
exit
函数,导致整个进程退出。
(3)取消(pthread_cancel
函数)目标线程。
既可以用主线程取消新线程,也可以用新线程取消主线程,不过不建议用新线程取消主线程。
pthread_cancel函数:
pthread_cancel
函数的作用:向一个线程发送一个取消请求,即一个线程去取消另一个线程。
pthread_cancel
函数的参数:
① thread 指定线程的 ID 。
pthread_cancel
函数的返回值:
① 成功,返回 0 。
② 错误,返回一个非零错误码。
示例:
① 主线程取消新线程。
我们发现,若一个线程是被取消的,那么它的退出结果是 -1 。
也就是说,如果发现一个线程退出时的退出结果是 -1 ,说明该线程是被取消的。
② 新线程取消主线程。
我们发现,若我们不回收线程,就会导致类似于僵尸进程的问题。
新线程是可以取消主线程的,但仅仅是让主线程退出(没有人等待主线程,所以最后这个资源是浪费的),而进程并没有退出,虽然进程已经不存在了。
这和 main 函数 return 不一样,main 函数 return 是让进程整体退出了。
4.线程分离
如果不关心其它线程的任务执行结果,可以进行线程分离。
被分离的线程运行完毕之后,会自动释放线程资源,可以类比为进程的signal(SIGCHLD, SIG_IGN);
。
pthread_detach 函数:
pthread_detach
函数的作用:分离一个线程(可以是线程组内的其他线程对目标线程进行分离,也可以是目标线程自己分离)。
pthread_detach
函数的参数:
① thread 指定线程的 ID 。
pthread_detach
函数的返回值:
① 成功,返回 0 。
② 错误,返回一个错误码。
一般是线程自己分离自己,当然也不排除主线程分离新线程。
一般线程分离的场景是主线程不退出,新线程处理业务,处理完毕再退出。
一旦一个线程被设置为分离,就不能再被 join 了。线程要么是 detach ,要么是被 join ,两者只能选其一。
若一个线程分离之后又被 join 。
由于新线程分离了,所以 pthread_join 函数等待新线程失败,返回一个错误码。
三、理解线程 ID
1.线程 ID 和 LWP
内核不区分进程和线程,都称之为 LWP(轻量级进程)。
线程库的线程 ID ,与内核 LWP 的值是不一样的。
LWP 是内核的,线程 ID 是线程库的。
① 内核的 LWP 属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
② pthread 库的线程 ID 属于线程库的范畴,它是一个虚拟内存地址。线程库的后续操作,都是根据该线程 ID 来操作线程的。
pthread_t 类型的线程 ID,本质就是进程地址空间上的一个地址。
2.线程 ID 该如何理解?
线程函数依赖 pthread 库。
Linux 系统都会内置 pthread 库,而 pthread 库是一个动态库。
pthread 库是一个文件,它要被进程访问,必须被加载到内存中,经过页表映射到进程地址空间中的共享区域。
第三方库会被加载到栈区和堆区之间的共享区域。
库当中就有一个很大的结构体数组(假设是 tcb tcbs[10000];)来维护众多的线程,每一个线程都对应这样的一个结构体,里面包含 struct pthread 线程控制块、线程局部存储和线程栈。
① struct pthread:每个线程都有自己的 struct pthread(描述线程的用户级控制块),当中包含线程的属性,在库里面,由库来维护。通过它,我们在用户层上能够获得线程相关的属性。
② 线程局部存储:每个线程都有自己的线程局部存储,当中包含了线程相关的数据。
③ 线程栈:每个线程都要有运行时的临时数据,就要求每个线程都要有自己的私有栈结构!在一个进程中,主线程的栈是进程地址空间中的栈。其它线程的栈在进程地址空间的共享区域中,也就是说,其它线程的栈结构实际上在库里面,由库来维护栈结构。
比如,系统内有 100 个进程,每个进程都创建 5 个线程,也就是一共有 500 个线程。因为库是共享库,所以在库里面就会有 500 个这样的结构,每一个结构都对应一个线程,换句话说,这个线程在用户层对应的临时数据是一定会被保存到这个结构里面的。所以,新线程使用的栈在库当中,由库来维护它的栈结构。
那么如何表示每一个不同的线程?如何快速地找到特定的一个线程呢?
找到特定线程最快的方式,是拿到它在共享区域中的虚拟地址。只要快速地拿到地址,就能找到这个线程的所有属性和数据。
所以,被映射进当前进程地址空间的 pthread 库内部的一个虚拟地址,用它来充当线程 ID 。
只要拿到线程 ID ,也就拿到了线程在库中的地址,然后就能拿到该线程的属性和运行时的用户级数据。
3.用户级线程和内核轻量级进程
用户级线程和内核轻量级进程,它们两个是 1:1 的对应关系。
① 用户级线程:在用户层调pthread_create
函数,会在库里面创建一个这个的结构,线程的临时数据在这个结构里保存。
② 内核轻量级进程:一定要在内核里面对应地创建一个 LWP ,而这个线程要被 CPU 调度,调度的是它的 PCB 。
用户层的 struct pthread 里面包含 LWP ,于是它们两个的关系便建立了起来。
这叫做用户级线程和内核轻量级进程一比一地对应,这就是 Linux 实现线程的方案。
四、线程互斥
1.线程安全
因为多个线程是共享地址空间的,也就是很多资源都是共享的,这既有优点也有缺点。
① 优点:通信方便。
② 缺点:缺乏访问控制。
缺乏访问控制的例子:
① 有一个全局 flag 变量,有一个线程对 flag 变量做判断,另一个线程不小心修改了 flag 变量,导致影响了其它线程的执行行为。
② 申请了堆空间,它的地址返回给了另一个线程,当一个线程正在访问该堆空间时,另一个线程把该堆空间给释放了。
③ 定义了一个 STL 容器,有两个线程都在访问该容器,当一个线程在插入时,另一个线程在扩容,或者当一个线程在删除时,另一个线程在插入。两个线程同时操作该容器,可能会出现问题。
因为一个线程的操作问题,给其他线程造成了不可控或者异常、逻辑不正确等现象,这些都是线程安全问题。
因此,想要保证一个函数没有线程安全问题的话,就尽量不要使用全局变量、STL、malloc、new 等会在全局内有效的数据。若要用的话,需要有访问控制。
相关的重要基本概念:
① 临界资源:凡是被线程共享访问的资源都是临界资源。(比如,多线程打印数据到显示器,显示器就是一种临界资源)
② 临界区:访问临界资源的代码。
③ 对临界区进行保护,本质就是对临界资源的保护,具体方式是互斥或者同步。
④ 原子性:一个事情要么不执行,要么就执行完毕。
⑤ 互斥:在任意时刻,只允许一个执行流访问某段代码(访问某部分资源)。
⑥ 同步:一般而言,让访问临界资源的过程在安全的前提下(一般都是互斥和原子的),让该过程具有一定的顺序性。同步让访问资源的过程具有合理性。
在大部分情况下,线程使用的数据都是局部变量,局部变量一定会压入对应线程的私有栈当中。这意味着,凡是在线程私有栈里面的数据都只属于该线程。
但是有些时候,变量需要在线程间共享,并且使用共享的内存区域进行线程间通信。
所以,当多个线程并发操作时,会带来一些问题。
举个例子:写一个简单的抢票逻辑。
每个线程在执行时,都会疯狂地抢票。
我们惊奇地发现,票数居然会出现负数,相当于多卖了票,这就有问题。
很显然,我们在购票的时候,不能出现负数的情况。
其实,tickets 就是临界资源,但 tickets-- 不是原子的(不是安全的)。
为什么它不是安全的?
tickets-- 这里虽然看起来是一行 C/C++ 代码,但它并非是原子的!在汇编级别,它对应多行代码:
① load:将共享变量 tickets 从内存加载到寄存器中。
② update:更新寄存器里面的值,执行 -1 操作。
③ move:将新值,从寄存器写回共享变量 tickets 的内存地址。
真正的汇编代码:
下面我们再来具体说明一下:
比如说在这里总共有两个线程在抢这 1000 张票。
当前的线程 A 要执行这三步:load、update 、move 。它在执行到任何一行代码处都有可能被中断和切换,所以它执行完 load 后刚准备执行-- 时,它就有可能被切走了,(此时 CPU 内寄存器的数据叫做当前执行流的上下文),当一个线程被切走时它的上下文数据要被保存起来,所以线程 A 带着它的数据(1000)就被切走了。
然后到线程 B 运行了,它从头到下开始执行这三行代码,假设线程 B 的优先级很高,竞争能力很强,一直在执行。在它运行期间,没有被打扰,最后可能把 1000 张票减到了 10 张票,也就是单单这个线程 B 就抢了 990 张票。当它把 10 load 到寄存器然后准备-- 时,不好意思,它被切走了,线程 B 此时要将 CPU 寄存器内的数据(10)作为自己执行流的上下文保存到它内部。
然后到线程 A 运行了,线程 A 恢复上下文(把数据 1000 放到寄存器中),接着上次中断的地方继续执行,把 1000-- 后变成了 999 ,执行 move ,把 999 写回内存中。
这就有问题了:本来线程 B 已经好不容易把 1000 张票抢到还剩 10 张了,而线程 A 却把 1000 又改回了 999 。因为多个线程在操作 tickets-- 时,一个线程对数据做修改可能影响了另一个线程。
上面只是分析了 tickets-- 的过程,更别谈还有 if 条件判断的检测了。
比如说票数是 1 ,一个线程判断完之后进来了,还没有执行 tickets-- 时就被切走了,另一个线程进来了,所以最终就出现剩余 1 张票时有两个线程进来执行 tickets-- ,把 tickets 减到负数的情况,所以 tickets 最终被减到负数也就可以理解了。
根本原因就在于 tickets-- 不是原子的。
那么怎么解决这个问题呢?
对临界区加上互斥锁!
2.互斥锁 mutex
我们需要用到以下几个与互斥锁(mutex)相关的函数:
① pthread_mutex_init
:初始化互斥锁。
② pthread_mutex_destroy
:销毁互斥锁。
③ pthread_mutex_lock
:加互斥锁(若申请锁成功,继续向后执行;若申请锁不成功,会被挂起等待)。
④ pthread_mutex_unlock
:解互斥锁。
若 mutex 互斥锁是全局的,或者是静态的,可以使用
PTHREAD_MUTEX_INITIALIZER
来完成初始化。需要注意的是,使用该方法初始化的互斥锁,最后不需要销毁。
线程安全的抢票代码:
我们把票和互斥锁封装进一个类里面,在类里专门封装一个
GetTicket
接口,由于使用了互斥锁,这个接口它就是安全的。
#include <iostream>
#include <cstdio>
#include <string>
#include <ctime>
#include <cstdlib>
#include <unistd.h>
#include <pthread.h>
class Ticket{
private:
int tickets; //票
pthread_mutex_t mtx; //互斥锁
public:
Ticket():tickets(1000)
{
pthread_mutex_init(&mtx, nullptr); //初始化锁
}
bool GetTicket()
{
//该bool变量不是被所有线程共享的
//因为它是局部变量,在栈上开辟,被线程私有
bool res = true;
pthread_mutex_lock(&mtx); //加锁
//执行这部分代码的执行流就是互斥的,串行执行的!
if (tickets > 0){
// 抢票
usleep(1000);
std::cout << "我是[" << pthread_self() << "] 我要抢的票是:" << tickets << std::endl;
tickets--;
}
else{
printf("票已经被抢空了\n");
res = false;
}
pthread_mutex_unlock(&mtx); //解锁
return res;
}
~Ticket()
{
pthread_mutex_destroy(&mtx); //销毁锁
}
};
void *ThreadRoutine(void *args)
{
Ticket *t = (Ticket*)args;
while (true){ //线程疯狂抢票
if(!t->GetTicket()){
break;
}
}
}
int main()
{
Ticket *t = new Ticket(); //创建一个封装了票和互斥锁的对象
pthread_t tid[5];
for (int i = 0; i < 5; i++){
pthread_create(tid + i, nullptr, ThreadRoutine, (void*)t);
}
for (int i = 0; i < 5; i++){
pthread_join(tid[i], nullptr);
}
delete t;
return 0;
}
一开始构建一个对象,然后一次创建出多个线程,让这些线程调用GetTicket
接口进行抢票。
上面代码的运行结果:
如果把加锁和解锁的代码都去掉,得到的运行结果会是:
这样就有问题了。
这样一对比就清楚互斥锁的作用了。
其实,C++11 里已经内置了 mutex 互斥锁,我们在使用它的时候不需要对它进行初始化和销毁,直接对临界区加锁和解锁就可以了。
3.互斥锁的实现原理
线程要访问临界资源 tickets ,需要先访问 mtx ,前提是所有线程必须得先看到它,那么锁本身也是临界资源。
那如何保证锁本身是安全的呢?
原理:lock
和unlock
是原子的!
mutex 互斥锁的实现原理:
我们认为,一行代码是原子的:只有一行汇编代码。
实际上,互斥锁它也是一个变量。可以将锁简单理解为一个整型变量(int mutex;),初始化之后的锁是 1 。
为了实现互斥锁操作,大多数体系结构都提供了 swap 或 exchange 指令,其作用是用一条汇编代码来完成内存和 CPU 寄存器数据的交换。由于只有一条汇编指令,保证了原子性。
下面是lock
和unlock
函数的伪代码:
比如,有两个线程 A 和 B ,它们都去申请这把锁(lock
函数):
-
当一个线程的代码被 CPU 执行时,CPU 内寄存器的数据就是该线程私有的,也就是该执行流的上下文。
CPU 把 A 切走然后调度 B 时,CPU 内寄存器的数据就会被保存到 A 中,然后 B 就会把它的数据写到 CPU 内的寄存器。
当线程 A 执行代码时,%al 内的数据就是线程 A 的上下文,若切换为线程 B ,%al 内的数据就是线程 B 的上下文。所以,线程 A 和 B 看起来都是在执行 mov 指令,但其实都是在设置自己的上下文数据,也就是说它们并不冲突。
所以,线程 A 和 B 各自执行movb $0, %al
指令时,都会把 0 设置进自己的上下文。 -
我们不知道谁先执行
xchgb %al, mutex
这条语句,所以可以假设是 A 先执行。exchange 指令的作用是用一条汇编代码来完成内存和 CPU 寄存器数据的交换,于是 A 的上下文就与 mutex 做了交换。
(A 的上下文:0->1,mutex:1->0)
xchgb %al, mutex
这条指令的本质是,使用一行汇编代码,原子性地完成共享内存数据 mutex 和某个线程的上下文的交换,从而实现私有的过程! -
当 A 准备执行
if(al寄存器的内容 > 0)
时,假设 A 被切走了,B 就会执行 xchg 指令,于是 B 的上下文就与 mutex 做了交换。
(B 的上下文:0->0,mutex:0->0,此时相当于什么都没做) -
当 A 和 B 各自执行
if(al寄存器的内容 > 0)
时,只有 A 判断为真,能进入到 if 分支里面(因为 A 的 %al 是 1),而 B 就不可以,此时就叫做 A 竞争锁成功,A 就会 return 0 ,申请锁成功了,继续执行lock
函数之后的代码。而 B 由于申请锁失败就会进入到 else 分支里面,从而被挂起,就没办法执行了,必须得等到 A 释放锁时才会被唤醒。若 A 释放锁,B 被唤醒,B 就会goto lock;
,回到最开始的地方,重新竞争锁。
以上就是线程申请互斥锁的基本过程。
线程申请 mutex 互斥锁的本质,其实就是通过一条汇编代码,将锁数据交换到自己的上下文中!
A 竞争锁成功后进入到临界区(临界区内可能存在多行代码),有可能在临界区内被切走,(申请锁是为了保证临界资源的安全,而不是为了保证临界区内部不可被切换,它们是两个概念),线程 A 被切走时,会做上下文的保护,而锁数据也是在上下文中的,换言之,该线程是 “抱着锁” 走的,即便是该线程被挂起了,其它线程在此期间不可能申请锁成功,也就不可能进入临界区。只有等待该线程释放锁,其它线程才能被唤醒,才能够竞争锁。
站在其它线程的视角,线程 A 要么没有申请,要么就使用完锁,这样就实现了线程 A 访问临界区的原子性!
五、线程安全和可重入
-
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
-
重入:同一个函数被不同的执行流调用,当一个执行流还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
线程安全描述的是线程之间互相影响的一种状态,而重入描述的是函数的状态。
(1)常见线程不安全的情况:
① 不保护共享变量的函数。
② 函数状态随着被调用,状态发生变化的函数。
③ 返回指向静态变量指针的函数。
④ 调用线程不安全函数的函数。
(2)常见线程安全的情况:
① 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
② 类或者接口对于线程来说都是原子操作。
③ 多个线程之间的切换不会导致该接口的执行结果存在二义性。
(3)常见不可重入的情况:
① 调用了malloc
/free
函数,因为malloc
函数是用全局链表来管理堆的。
② 调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。
③ 可重入函数体内使用了静态的数据结构。
(4)常见可重入的情况:
① 不使用全局变量或静态变量。
② 不使用malloc
或者new
开辟出的空间。
③ 不调用不可重入函数。
④ 不返回静态或全局数据,所有数据都由函数的调用者提供。
⑤ 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
(5)可重入与线程安全的联系:
① 函数是可重入的,那就是线程安全的。
② 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
③ 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
(6)可重入与线程安全的区别:
① 可重入函数是线程安全函数的一种。
② 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。比如,对同一把锁申请两次,就会导致死锁。
六、死锁
死锁是指在一组执行流中的各个执行流均占有不会释放的资源,但因互相申请被其他执行流所占用不会释放的资源而处于的一种永久等待状态。
死锁四个必要条件:
① 互斥条件:一个资源每次只能被一个执行流使用。
② 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
③ 不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
④ 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
避免死锁:
① 破坏死锁的四个必要条件。
② 加锁顺序一致。
③ 避免锁未释放的场景。
④ 资源一次性分配。
七、线程同步
同步是指在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。
1.条件变量
一般而言,在只有互斥锁的情况下,线程要想知道临界资源的状态是比较困难的,因此我们就需要引入条件变量。
我们需要用到以下几个与条件变量相关的函数:
① pthread_cond_init
:初始化条件变量。
② pthread_cond_destroy
:销毁条件变量。
③ pthread_cond_signal
:唤醒在条件变量下等待的一个线程。
④ pthread_cond_wait
:在条件变量下等待(调用时,会首先自动释放互斥锁,然后再挂起自己;被唤醒时,会首先自动竞争锁,获取到锁之后才能返回)。
⑤ pthread_cond_broadcast
:唤醒在条件变量下等待的所有线程。
我们先来一段测试代码:
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mtx;
pthread_cond_t cond;
//master线程控制worker线程,让他定期运行
void *ctrl(void *args)
{
std::string name = (char*)args;
while(true){
std::cout << "master says : begin work" << std::endl;
pthread_cond_signal(&cond);
sleep(5);
}
}
void *work(void *args)
{
int number = *(int*)args;
delete (int*)args;
while(true){
//此处我们的mutex不用,暂时这样写,后面解释
pthread_cond_wait(&cond, &mtx);
std::cout << "worker: " << number << " is working..." << std::endl;
}
}
int main()
{
#define NUM 3
pthread_mutex_init(&mtx, nullptr);
pthread_cond_init(&cond, nullptr);
pthread_t master;
pthread_t worker[NUM];
pthread_create(&master, nullptr, ctrl, (void*)"boss");
for(int i = 0; i < NUM; ++i){
int *number = new int(i);
pthread_create(worker+i, nullptr, work, (void*)number);
}
for(int i = 0; i < NUM; ++i){
pthread_join(worker[i], nullptr);
}
pthread_join(master, nullptr);
pthread_mutex_destroy(&mtx);
pthread_cond_destroy(&cond);
return 0;
}
上面代码的运行结果:
我们发现,worker 线程在刚开始等待时是什么顺序,那么之后它就是什么顺序。
其实,在条件变量内部存在一个等待队列。线程在条件变量下等待,就是线程被链到了该条件变量的等待队列中。另一个线程通过pthread_cond_signal
唤醒在该条件变量下等待的一个线程,就是在该条件变量的等待队列里等待的第一个线程。
2.生产者消费者模型
在生活中典型的生产者消费者模型是超市。
为什么要有超市?
减少交易成本,提高效率。
生产者消费者模型的优点:
① 提高效率。
② 将生产环节和消费环节进行了解耦,保证了生产过程和消费过程是可以同步的。
“321”原则:
(1)“3 种关系”:
① 生产者和生产者之间的关系:互斥。
② 消费者和消费者之间的关系:互斥。
③ 生产者和消费者之间的关系:互斥和同步。
(2)“2 种角色”:
生产者和消费者,在代码层面上都是执行流。
(3)“1 个交易场所”:
超市 -> 交易场所,在代码层面上是一段缓冲区(内存空间,STL 容器等),超市本身就是一份临界资源。
问:为何要使用生产者消费者模型?
答:生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
在生产者消费者模型中,把数据放进去和把数据拿出来不是主要矛盾,制造数据和消费数据才是主要矛盾,生产者消费者模型解决的是制造数据和消费数据的同步问题,提高并发性。
进程间通信的本质,就是一种生产者消费者模型。
3.基于 BlockingQueue 的生产者消费者模型
我们用 C++ 的 queue 来进行模拟。
下面共有三个文件,分别是:
① CpTest.cc:主函数。
② BlockingQueue.hpp:阻塞队列的声明和定义。
③ Task.hpp:任务的声明和定义。
- CpTest.cc:
#include "BlockingQueue.hpp"
#include "Task.hpp"
#include <time.h>
#include <cstdlib>
#include <unistd.h>
using namespace ns_blockingqueue;
using namespace ns_task;
void *consumer(void *args)
{
BlockingQueue<Task> *bq = (BlockingQueue<Task>*)args;
while(true){
Task t;
bq->Pop(&t); //这里完成了任务消费的第1步
t(); //这里完成了任务消费的第2步
// sleep(2);
// int data = 0;
// bq->Pop(&data);
// std::cout << "消费者消费了一个数据:" << data << std::endl;
}
}
void *producer(void *args)
{
BlockingQueue<Task> *bq = (BlockingQueue<Task>*)args;
const std::string ops = "+-*/%";
while(true){
//1. 制造数据(task)
int x = rand()%20 + 1; //[1,20]
int y = rand()%10 + 1; //[1,10]
char op = ops[rand()%ops.size()];
Task t(x, y, op);
std::cout << "生产者派发了一个任务:" << x << op << y << "=?" << std::endl;
//2. 将数据推送到任务队列中
bq->Push(t);
sleep(1);
// sleep(2);
// int data = rand()%20 + 1;
// std::cout << "生产者生产数据:" << data << std::endl;
// bq->Push(data);
}
}
int main()
{
srand((long long)time(nullptr));
BlockingQueue<Task> *bq = new BlockingQueue<Task>();
pthread_t c,p;
pthread_t c1,c2,c3,c4;
pthread_create(&c, nullptr, consumer, (void*)bq);
pthread_create(&c1, nullptr, consumer, (void*)bq);
pthread_create(&c2, nullptr, consumer, (void*)bq);
pthread_create(&c3, nullptr, consumer, (void*)bq);
pthread_create(&c4, nullptr, consumer, (void*)bq);
pthread_create(&p, nullptr, producer, (void*)bq);
pthread_join(c, nullptr);
pthread_join(c1, nullptr);
pthread_join(c2, nullptr);
pthread_join(c3, nullptr);
pthread_join(c4, nullptr);
pthread_join(p, nullptr);
delete bq;
return 0;
}
- BlockingQueue.hpp:
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
namespace ns_blockingqueue
{
const int default_cap = 5;
template <class T>
class BlockingQueue
{
private:
std::queue<T> bq_; //我们的阻塞队列
int cap_; //队列的元素上限
pthread_mutex_t mtx_; //保护临界资源的锁
//1. 当生产满了的时候,就应该不要生产了(不要竞争锁了),而应该让消费者来消费
//2. 当消费空了,就不应该消费了(不要竞争锁了),应该让生产者来进行生产
pthread_cond_t is_full_; //bq_满的,消费者在该条件变量下等待
pthread_cond_t is_empty_; //bq_空的,生产者在该条件变量下等待
private:
bool IsFull()
{
return bq_.size() == cap_;
}
bool IsEmpty()
{
return bq_.size() == 0;
}
void LockQueue()
{
pthread_mutex_lock(&mtx_);
}
void UnlockQueue()
{
pthread_mutex_unlock(&mtx_);
}
void ProducerWait()
{
//pthread_cond_wait
//1. 调用的时候,会首先自动释放mtx_,然后再挂起自己
//2. 被唤醒的时候,会首先自动竞争锁,获取到锁之后,才能返回
pthread_cond_wait(&is_empty_, &mtx_);
}
void ConsumerWait()
{
pthread_cond_wait(&is_full_, &mtx_);
}
void WakeupConsumer()
{
pthread_cond_signal(&is_full_);
}
void WakeupProducer()
{
pthread_cond_signal(&is_empty_);
}
public:
BlockingQueue(int cap = default_cap):cap_(cap)
{
pthread_mutex_init(&mtx_, nullptr);
pthread_cond_init(&is_empty_, nullptr);
pthread_cond_init(&is_full_, nullptr);
}
~BlockingQueue()
{
pthread_mutex_destroy(&mtx_);
pthread_cond_destroy(&is_empty_);
pthread_cond_destroy(&is_full_);
}
public:
void Push(const T &in)
{
LockQueue();
//临界区
//这里需要使用循环检测
//来保证退出循环一定是因为条件不满足导致的!
while(IsFull()){
//等待,把线程挂起,我们当前是持有锁的!
ProducerWait();
}
//向队列中放数据,生产函数
bq_.push(in);
// if(bq_.size() > cap_/2) WakeupConsumer();
UnlockQueue();
//只有生产者知道消费者应该什么时候消费
WakeupConsumer(); //放在unlock前或unlock后均可
}
void Pop(T *out)
{
LockQueue();
//临界区
//这里需要使用循环检测
//来保证退出循环一定是因为条件不满足导致的!
while(IsEmpty()){
//无法消费
ConsumerWait();
}
//从队列中拿数据,消费函数
*out = bq_.front();
bq_.pop();
// if(bq_.size() < cap_/2) WakeupProducer();
UnlockQueue();
//只有消费者知道生产者应该什么时候生产
WakeupProducer(); //放在unlock前或unlock后均可
}
};
}
- Task.hpp:
#pragma once
#include <iostream>
#include <pthread.h>
namespace ns_task
{
class Task
{
private:
int x_;
int y_;
char op_; //+-*/%
public:
Task(){}
Task(int x, int y, char op):x_(x), y_(y), op_(op)
{}
int Run()
{
int res = 0;
switch(op_)
{
case '+':
res = x_ + y_;
break;
case '-':
res = x_ - y_;
break;
case '*':
res = x_ * y_;
break;
case '/':
res = x_ / y_;
break;
case '%':
res = x_ % y_;
break;
default:
std::cout << "bug??" << std::endl;
break;
}
std::cout << "当前任务正在被:" << pthread_self() << " 处理:" \
<< x_ << op_ << y_ << "=" << res << std::endl;
return res;
}
int operator()()
{
return Run();
}
~Task(){}
};
}
问:在条件变量等待函数那里为什么要用 while 循环检测?
答:单纯地直接使用 if 来进行判断是不太完善的,因为生产者/消费者线程有可能 Wait 挂起失败,或者被伪唤醒了(伪唤醒是指条件并没有满足,但是线程被唤醒了),该线程就会继续向下执行,插入了不该插入的数据或者往空队列里取数据。其实这里最本质的问题是生产/消费条件不具备。所以当生产者/消费者醒来时,不应该着急地向下执行,而是应该再做一次条件判断,若判断结果为假,说明生产/消费条件具备,则继续向下执行。若判断结果为真,说明生产消费条件不具备,则再次将自己挂起。因此我们用 while 来进行循环检测,这样的话就能够保证退出循环一定是因为生产/消费条件具备。
4.信号量
信号量本质就是一把计数器,描述临界资源中资源数目的大小。若其被合理使用,能够达到对临界资源进行预定的目的。类比看电影买票。
如果临界资源可以被划分为一个一个的小资源,并且处理等当,我们也有可能让多个线程同时访问临界资源的不同区域,从而实现并发。
我们可以结合信号量,在应用层中设计出能够让不同执行流访问临界资源的不同区域的方案,从而设计出临界资源的一种预定机制。
每个线程要想访问临界资源,都得先申请信号量资源。只要申请到了,一定会有该线程的小块资源的。
信号量本质是一个计数器。
- P 操作:安全地对信号量进行 - - 操作。
- V 操作:安全地对信号量进行 ++ 操作。
我们需要用到以下几个与信号量相关的函数:
① sem_init
:初始化信号量。
② sem_destroy
:销毁信号量。
③ sem_wait
:P 操作,申请信号量,将信号量的值减 1(若申请成功,继续向后执行;若申请不成功,会被挂起等待)。
④ sem_post
:V 操作,释放信号量,将信号量的值加 1 。
5.基于环形队列的生产者消费者模型
我们采用数组模拟环形队列,用模运算来模拟环状特性。
(1)基本原理:
① 当队列不为空且不为满时,生产者和消费者指向不同的位置,可以并发执行。
② 当队列为空或为满时,生产者和消费者指向同一个位置。为空时,让生产者先生产;为满时,让消费者先消费。
(2)基本实现思想:
生产者,最关心的资源是环形队列中的空位置。
消费者,最关心的资源是环形队列中的数据。
规则1:生产者不能把消费者套一个圈。
规则2:消费者不能超过生产者。
规则3:当指向同一个位置的时候,要根据空或满的状态,来判断让谁先执行。
其它:除此之外,生产和消费可以并发执行。
通过申请自己关心的资源,释放对方关心的资源的这种方式,能满足以上全部的规则,完成了一个基于环形队列的生产者消费者模型。
下面是代码实现,共有三个文件,分别是:
① ring_cp.cc:主函数。
② ring_queue.hpp:环形队列的声明和定义。
③ Task.hpp:任务的声明和定义。
- ring_cp.cc:
#include "ring_queue.hpp"
#include <pthread.h>
#include <time.h>
#include <unistd.h>
#include "Task.hpp"
using namespace ns_ring_queue;
using namespace ns_task;
void *consumer(void *args)
{
RingQueue<Task> *rq = (RingQueue<Task> *)args;
while(true){
Task t;
rq->Pop(&t);
// std:: cout << "消费数据是:" << t.Show() << t() << " 我是:" << pthread_self() << std::endl;
t();
sleep(1);
}
}
void *producer(void *args)
{
RingQueue<Task> *rq = (RingQueue<Task> *)args;
const std::string ops = "+-*/%";
while(true){
int x = rand()%20 + 1;
int y = rand()%10 + 1;
char op = ops[rand()%ops.size()];
Task t(x, y, op);
std::cout << "生产数据是:" << t.Show() << " 我是:" << pthread_self() << std::endl;
rq->Push(t);
// sleep(1);
}
}
int main()
{
srand((long long)time(nullptr));
RingQueue<Task> *rq = new RingQueue<Task>();
pthread_t c0,c1,c2,c3,p0,p1,p2;
pthread_create(&c0, nullptr, consumer, (void *)rq);
pthread_create(&c1, nullptr, consumer, (void *)rq);
pthread_create(&c2, nullptr, consumer, (void *)rq);
pthread_create(&c3, nullptr, consumer, (void *)rq);
pthread_create(&p0, nullptr, producer, (void *)rq);
pthread_create(&p1, nullptr, producer, (void *)rq);
pthread_create(&p2, nullptr, producer, (void *)rq);
pthread_join(c0, nullptr);
pthread_join(c1, nullptr);
pthread_join(c2, nullptr);
pthread_join(c3, nullptr);
pthread_join(p0, nullptr);
pthread_join(p1, nullptr);
pthread_join(p2, nullptr);
return 0;
}
- ring_queue.hpp:
#pragma once
#include <iostream>
#include <vector>
#include <pthread.h>
#include <semaphore.h>
namespace ns_ring_queue
{
const int g_cap_default = 10;
template <class T>
class RingQueue
{
private:
std::vector<T> ring_queue_;
int cap_;
//生产者关心空位置资源
sem_t blank_sem_;
//消费者关心数据资源
sem_t data_sem_;
int c_step_;
int p_step_;
pthread_mutex_t c_mtx_;
pthread_mutex_t p_mtx_;
public:
RingQueue(int cap = g_cap_default): ring_queue_(cap), cap_(cap)
{
sem_init(&blank_sem_, 0, cap);
sem_init(&data_sem_, 0, 0);
c_step_ = p_step_ = 0;
pthread_mutex_init(&c_mtx_, nullptr);
pthread_mutex_init(&p_mtx_, nullptr);
}
~RingQueue()
{
sem_destroy(&blank_sem_);
sem_destroy(&data_sem_);
pthread_mutex_destroy(&c_mtx_);
pthread_mutex_destroy(&p_mtx_);
}
public:
void Push(const T &in)
{
//生产接口
sem_wait(&blank_sem_); //P(空位置)
pthread_mutex_lock(&p_mtx_);
//可以生产了
ring_queue_[p_step_] = in;
p_step_++;
p_step_ %= cap_;
pthread_mutex_unlock(&p_mtx_);
sem_post(&data_sem_); //V(数据)
}
void Pop(T *out)
{
//消费接口
sem_wait(&data_sem_); //P(数据)
pthread_mutex_lock(&c_mtx_);
*out = ring_queue_[c_step_];
c_step_++;
c_step_ %= cap_;
pthread_mutex_unlock(&c_mtx_);
sem_post(&blank_sem_); //V(空位置)
}
};
}
- Task.hpp:
#pragma once
#include <iostream>
#include <pthread.h>
namespace ns_task
{
class Task
{
private:
int x_;
int y_;
char op_; //+-*/%
public:
Task(){}
Task(int x, int y, char op):x_(x), y_(y), op_(op)
{}
std::string Show()
{
std::string message = std::to_string(x_);
message += op_;
message += std::to_string(y_);
message += "=?";
return message;
}
int Run()
{
int res = 0;
switch(op_)
{
case '+':
res = x_ + y_;
break;
case '-':
res = x_ - y_;
break;
case '*':
res = x_ * y_;
break;
case '/':
res = x_ / y_;
break;
case '%':
res = x_ % y_;
break;
default:
std::cout << "bug??" << std::endl;
break;
}
std::cout << "当前任务正在被:" << pthread_self() << " 处理:" \
<< x_ << op_ << y_ << "=" << res << std::endl;
return res;
}
int operator()()
{
return Run();
}
~Task(){}
};
}
八、线程池
线程池是一种线程使用模式。
线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务,这避免了在处理短时间任务时创建与销毁线程的代价。
线程池不仅能够保证内核的充分利用,还能防止过分调度。
以下写的线程池,本质就是基于阻塞队列的生产者消费者模型,只不过是把创建线程的工作放在了类内。
下面是代码实现,共有三个文件,分别是:
① main.cc:主函数。
② thread_pool.hpp:线程池的声明和定义。
③ Task.hpp:任务的声明和定义。
- main.cc:
#include "thread_pool.hpp"
#include "Task.hpp"
#include <ctime>
#include <cstdlib>
using namespace ns_threadpool;
using namespace ns_task;
int main()
{
ThreadPool<Task> *tp = new ThreadPool<Task>(10);
tp->InitThreadPool();
srand((long long)time(nullptr));
while(true)
{
// sleep(1);
//网络
Task t(rand()%20+1, rand()%10+1, "+-*/%"[rand()%5]);
tp->PushTask(t);
}
return 0;
}
- thread_pool.hpp:
#pragma once
#include <iostream>
#include <string>
#include <queue>
#include <unistd.h>
#include <pthread.h>
namespace ns_threadpool
{
const int g_num = 5;
template<class T>
class ThreadPool
{
private:
int num_;
std::queue<T> task_queue_; //该成员是一个临界资源
pthread_mutex_t mtx_;
pthread_cond_t cond_;
public:
void Lock()
{
pthread_mutex_lock(&mtx_);
}
void Unlock()
{
pthread_mutex_unlock(&mtx_);
}
void Wait()
{
pthread_cond_wait(&cond_, &mtx_);
}
void Wakeup()
{
pthread_cond_signal(&cond_);
}
bool IsEmpty()
{
return task_queue_.empty();
}
public:
ThreadPool(int num = g_num):num_(num)
{
pthread_mutex_init(&mtx_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
//在类中要让线程执行类内成员方法,是不可行的,原因:隐含的参数this
//所以必须让线程执行静态方法
static void *Routine(void *args)
{
pthread_detach(pthread_self());
ThreadPool<T> *tp = (ThreadPool<T>*)args;
while(true)
{
tp->Lock();
while(tp->IsEmpty())
{
//任务队列为空
tp->Wait();
}
//该任务队列中一定有任务了
T t;
tp->PopTask(&t);
tp->Unlock();
t();
}
}
void InitThreadPool()
{
pthread_t tid;
for(int i = 0; i < num_; ++i){
pthread_create(&tid, nullptr, Routine, (void*)this);
}
}
void PushTask(const T &in)
{
Lock();
task_queue_.push(in);
Unlock();
Wakeup();
}
void PopTask(T *out)
{
*out = task_queue_.front();
task_queue_.pop();
}
~ThreadPool()
{
pthread_mutex_destroy(&mtx_);
pthread_cond_destroy(&cond_);
}
};
} // namespace ns_threadpool
- Task.hpp:
#pragma once
#include <iostream>
#include <pthread.h>
namespace ns_task
{
class Task
{
private:
int x_;
int y_;
char op_; //+-*/%
public:
Task(){}
Task(int x, int y, char op):x_(x), y_(y), op_(op)
{}
std::string Show()
{
std::string message = std::to_string(x_);
message += op_;
message += std::to_string(y_);
message += "=?";
return message;
}
int Run()
{
int res = 0;
switch(op_)
{
case '+':
res = x_ + y_;
break;
case '-':
res = x_ - y_;
break;
case '*':
res = x_ * y_;
break;
case '/':
res = x_ / y_;
break;
case '%':
res = x_ % y_;
break;
default:
std::cout << "bug??" << std::endl;
break;
}
std::cout << "当前任务正在被:" << pthread_self() << " 处理:" \
<< x_ << op_ << y_ << "=" << res << std::endl;
return res;
}
int operator()()
{
return Run();
}
~Task(){}
};
}
九、线程安全的单例模式
- 设计模式:其实所谓的设计模式,本质上就是一种编程经验和思想总结。
- 单例模式:是一种 “经典的、常用的、常考的” 设计模式,其特点是类只能创建一个对象(实例)。在很多服务器开发场景中,经常需要让服务器加载很多的数据(上百G)到内存中,此时往往要用一个单例的类来管理这些数据。
实现单例模式的两种方式:
① 饿汉方式:吃完饭,立刻洗碗,下一顿吃的时候可以立刻拿着碗吃饭。
② 懒汉方式:吃完饭,先把碗放下,然后下一顿饭用到这个碗了再洗碗。
懒汉方式最核心的思想是 “延时加载”,从而能够优化服务器的启动速度。
1.懒汉方式实现单例模式
我们以上面的 ThreadPool 类为例,对其进行一定的修改,便可使其成为单例类。
//在ThreadPool类中增加静态成员变量
static ThreadPool<T> *ins;
//在ThreadPool类外完成该静态成员变量的初始化
template <class T>
ThreadPool<T> *ThreadPool<T>::ins = nullptr;
//在ThreadPool类中增加静态成员函数
static ThreadPool<T> *GetInstance()
{
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
if (ins == nullptr) //双判定,减少锁的争用,提高获取单例的效率!
{
pthread_mutex_lock(&lock);
// 当前单例对象还没有被创建
if (ins == nullptr)
{
ins = new ThreadPool<T>();
ins->InitThreadPool();
std::cout << "首次加载对象" << std::endl;
}
pthread_mutex_unlock(&lock);
}
return ins;
}
//将构造函数私有化,并删除拷贝构造函数和赋值重载函数
private:
// 构造函数必须得实现,但是必须得私有化
ThreadPool(int num = g_num) : num_(num)
{
pthread_mutex_init(&mtx_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
ThreadPool(const ThreadPool<T> &tp) = delete;
ThreadPool<T> &operator=(const ThreadPool<T> &tp) = delete;
即:
//thread_pool.hpp
#pragma once
#include <iostream>
#include <string>
#include <queue>
#include <unistd.h>
#include <pthread.h>
namespace ns_threadpool
{
const int g_num = 5;
template <class T>
class ThreadPool
{
private:
int num_;
std::queue<T> task_queue_; // 该成员是一个临界资源
pthread_mutex_t mtx_;
pthread_cond_t cond_;
static ThreadPool<T> *ins;
private:
// 构造函数必须得实现,但是必须得私有化
ThreadPool(int num = g_num) : num_(num)
{
pthread_mutex_init(&mtx_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
ThreadPool(const ThreadPool<T> &tp) = delete;
ThreadPool<T> &operator=(const ThreadPool<T> &tp) = delete;
public:
static ThreadPool<T> *GetInstance()
{
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
if (ins == nullptr) //双判定,减少锁的争用,提高获取单例的效率!
{
pthread_mutex_lock(&lock);
// 当前单例对象还没有被创建
if (ins == nullptr)
{
ins = new ThreadPool<T>();
ins->InitThreadPool();
std::cout << "首次加载对象" << std::endl;
}
pthread_mutex_unlock(&lock);
}
return ins;
}
void Lock()
{
pthread_mutex_lock(&mtx_);
}
void Unlock()
{
pthread_mutex_unlock(&mtx_);
}
void Wait()
{
pthread_cond_wait(&cond_, &mtx_);
}
void Wakeup()
{
pthread_cond_signal(&cond_);
}
bool IsEmpty()
{
return task_queue_.empty();
}
public:
// 在类中要让线程执行类内成员方法,是不可行的,原因:隐含的参数this
// 所以必须让线程执行静态方法
static void *Routine(void *args)
{
pthread_detach(pthread_self());
ThreadPool<T> *tp = (ThreadPool<T> *)args;
while (true)
{
tp->Lock();
while (tp->IsEmpty())
{
// 任务队列为空
tp->Wait();
}
// 该任务队列中一定有任务了
T t;
tp->PopTask(&t);
tp->Unlock();
t();
}
}
void InitThreadPool()
{
pthread_t tid;
for (int i = 0; i < num_; ++i)
{
pthread_create(&tid, nullptr, Routine, (void *)this);
}
}
void PushTask(const T &in)
{
Lock();
task_queue_.push(in);
Unlock();
Wakeup();
}
void PopTask(T *out)
{
*out = task_queue_.front();
task_queue_.pop();
}
~ThreadPool()
{
pthread_mutex_destroy(&mtx_);
pthread_cond_destroy(&cond_);
}
};
template <class T>
ThreadPool<T> *ThreadPool<T>::ins = nullptr;
} // namespace ns_threadpool
//main函数
int main()
{
std::cout << "当前正在运行我的进程其他代码..." << std::endl;
std::cout << "当前正在运行我的进程其他代码..." << std::endl;
std::cout << "当前正在运行我的进程其他代码..." << std::endl;
std::cout << "当前正在运行我的进程其他代码..." << std::endl;
std::cout << "当前正在运行我的进程其他代码..." << std::endl;
std::cout << "当前正在运行我的进程其他代码..." << std::endl;
std::cout << "当前正在运行我的进程其他代码..." << std::endl;
sleep(5);
srand((long long)time(nullptr));
while(true)
{
sleep(1);
//网络
Task t(rand()%20+1, rand()%10+1, "+-*/%"[rand()%5]);
ThreadPool<Task>::GetInstance()->PushTask(t);
//单例本身会在任何场景、任何环境下被调用
//GetInstance()需考虑多线程重入的情况,避免线程安全问题
std::cout << ThreadPool<Task>::GetInstance() << std::endl; //测试是否为同一个对象
}
return 0;
}
问:在获取单例函数中,为什么又外加了一层条件判断?
答:若不加外面的一层条件判断:当单例已经被创建之后,每个线程竞争锁,竞争到锁的线程在内部做检测,检测不为空,然后释放锁,然后再返回对象指针。而争锁的过程实际上是一个串行的过程,会降低效率。
所以,我们最常见的做法就是外面再加一层条件判断,构成双判断。尽管外面的条件判断有线程安全问题(但函数整体是线程安全的),申请锁成功的线程创建完单例然后释放锁,从此往后,指针指向了对象的地址,不为空了,所有后续的线程做检测时都会条件不满足,于是也就不会再进入里面竞争锁了,直接返回对象指针。这样就能够减少锁的争用,提高获取单例的效率。
十、STL 、智能指针和线程安全
(1)STL 中的容器是否是线程安全的?
不是,原因是 STL 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,就会对性能造成巨大的影响。
而且对于不同的容器,加锁方式的不同,性能可能也不同(例如 hash 表的锁表和锁桶)。
因此 STL 默认不是线程安全的。如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
(2)智能指针是否是线程安全的?
① 对于 unique_ptr ,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。
② 对于 shared_ptr ,多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式保证 shared_ptr 能够高效、原子地操作引用计数。
十一、读者写者模型
读者写者模型:
① 对数据,大部分的操作是读取,少量的操作是写入。
② 判断依据:进行数据读取(消费)的一端,是否会将数据取走,如果不取走,就可以考虑读者写者模型。
类比出黑板报。
其思考方式跟生产者消费者模型一样:“321”原则。
(1)3 种关系:
① 写者和写者:互斥。
② 读者和写者:互斥和同步。
③ 读者和读者:没有关系。
问:生产者消费者与写者读者相比,为什么前两种关系是相同的,而第三种关系是不同的呢?
答:根本原因:读者不会取走数据,而消费者会拿走数据,所以读者和读者之间没有关系。
(2)2 种角色:
读者和写者,由线程承担。
(3)1 个交易场所:
一段缓冲区(自己申请的或 STL 容器)。
我们需要用到的几个与读写锁相关的函数:
① pthread_rwlock_init
:初始化读写锁。
② pthread_rwlock_destroy
:销毁读写锁。
③ pthread_rwlock_rdlock
:以读者身份加读写锁。
④ pthread_rwlock_wrlock
:以写者身份加读写锁。
⑤ pthread_rwlock_unlock
:解读写锁。
如何理解?
实际上在实现的时候,有两种优先级策略:
① 读者优先:读者和写者同时到来的时候,我们让读者先进入访问。
② 写者优先:读者和写者同时到来的时候,比当前写者晚来的所有读者,都不要进入临界区访问了,等临界区中没有读者的时候,让写者先写入。
实际上,在读者写者模型中,我们默认采用读者优先策略。
问:在读者写者模型中本身就是读者多写者少,如果读者不间断地来,写者就会没机会写入,不就存在写饥饿问题吗?
答:因为我们解决的场景本来就是读的多写的少,有写饥饿问题并不代表永远不让写入,而是让写者在读者都真正读完之后再写入,所以 “写饥饿” 这个词,我们在这里理解为中性词。
十二、挂起等待特性的锁 vs 自旋锁
- 挂起等待特性的锁:若锁被其它线程占用,当前线程申请锁时会被挂起等待。
- 自旋锁:若锁被其它线程占用,当前线程申请锁时会不断地通过循环,检测锁的状态。
我们需要用到的几个与自旋锁相关的函数:
① pthread_spin_init
:初始化自旋锁。
② pthread_spin_destroy
:销毁自旋锁。
③ pthread_spin_lock
:加自旋锁。
④ pthread_spin_unlock
:解自旋锁。