Linux:万字博客带你学会线程!

进程的简述

在这之前,我们一定对于进程是有所耳闻的,也应该见证过进程的形态及功能。

在终端中,我们可以使用 ps 指令来查看当前 OS 中,有哪些进程。

复制代码
ps

而且进程也有一些特殊的性质。

1、进程的独立性:每一个进程都不会互相干扰,对于全局变量,在一个进程中发生改变,但在另一个进程中不会发生,原理就是发生了写时拷贝。(当进程中将对公共的资源进行修改的时候,将原来的虚拟地址空间重新拷贝给新的一个虚拟地址空间,然后重新在内存中重新申请一份空间给这一个新的进程,同时再重新构建一个新的页表)

2、进程的并发性:每一个进程都可以同时进行运行,这种做法有效的提高了任务的处理效率。

3、进程的异步性:每一个进程的运行速度都不相同。

而下面的线程跟进程有一些异曲同工之妙,接下来就是来介绍本篇主角:线程

线程的基本介绍

1、概念

想要了解一个东西,首先一定要先知道其对应的概念,这可以帮助提高对于一个新事物的理解。

那么线程的概念是什么呢?

在一个程序里的一个执行路线就是线程。这句话看起来可能有点抽象,白话来说就是一个程序里的 "进程" 。但是并不是我们熟知的进程,而是一种叫做轻量型进程的概念,其就是没有发生写时拷贝的进程。

那么,有人可能就会有疑问:那没有发生写时拷贝,那是不是就没有了独立性?

对于这个问题,应该算是有的,但是不多,因为线程虽然没有发生写时拷贝,但是有一个新的独立的栈结构,因此,线程也就具备了一定的独立性,但是,其实这个独立的栈结构还是在同一个虚拟地址空间中开辟的。

首先,我来解释一下,为什么线程被称为轻量级进程?

从上面的这张图可以看到,线程对应的 PCB 还是直接连接在同一个虚拟地址空间上的,没有像进程一样,发生写时拷贝,然后重新创建一个新的虚拟地址空间,这也就是我们称线程为轻量级的地方就在这里,之后发生的一切动作都是在这同一个虚拟地址空间上的。

然后,还得再来看一下 CPU 对于任务的处理。过去我们接触 CPU 的时候,都以为其是以进程为单位放到 CPU 上运行的,但事实上,是因为我们过去接触到的进程都是单线程的,CPU 真正在处理任务的时候,实际上是以 PCB 为单位的,将 PCB 放到 CPU 上,然后对该 PCB 上的任务进行处理。

注意:虽然 CPU 是以线程为单位来运行代码的,但是有一点值得注意,信号是以进程为单位的,因为假如 OS / USER 向进程发送信号,那大概率这个进程已经在某一部分出问题了,所以,此时,OS 肯定会将该进程的资源回收了,由于进程中没有了资源,那么线程就自然而然地无法继续向下执行代码,所以,该进程对应的线程也就自然地停下来了,这个过程给相当于线程也收到了信号,整体下来就是信号以进程为单位。

EXTRA_1 对字符串常量进行修改报错的原因

我们过去都知道,相较 char* str = "hello world" ,这样的代码,假如我接下来写 *str = "L",这时候编译器就会报错

c 复制代码
char* str = "hello world";
*str = "L";//err:不能将 "const char *" 类型的值分配到 "char" 类型的实体

这里报错的真实原因其实是:str 这个指针指向了 "hello world" 这个字符串常量,当 *str 的时候,其实是找到的是 str 指向的第一个位置对应的内容,但是字符串常量存储在了代码区中,从上面的那张图里,我们能够看见代码运行的基本逻辑,当要对虚拟地址空间上的数据进行修改的时候,首先会通过页表查到对应的物理内存,然后,判断是否命中对应的数据,然后再开始检查对应 RWX 权限和 U/K 权限,我们这里的 *str = L 是对 str 指向的内容进行修改,通过上面的页表看到了代码区的 RWX 权限为只读,不能修改,这就是为什么我们对其进行修改会报错了,这个也是只有在操作系统的层面上,我们才能够将这部分内容看懂。这里顺便讲一下 U/K 权限,其实就是 用户态和内核态,当我们的代码需要内核态的时候,OS 会先去 CPU 上查看对应的用户权限,假如权限为 K 符合,则进行跳转到对应的物理内存上,然后继续向下执行代码。

在此基础上,我们可以得到一些结论

1、虚拟地址空间就是我们能看到的资源窗口。

2、页表决定了进程拥有的资源。

3、合理的对地址空间和页表进行资源划分,就可以做到对资源的分类。

EXTRA_2 页表的理解

页表很多人一开始都会将其理解为就是一个有 2^32 位置的一张表,每一个位置代表着虚拟地址中的每一个位置,但是周所周知,一个虚拟地址空间的大小为 2^32 比特,换算下来就是 4KB,介于写时拷贝会发生构建一张页表,那么就是又增加 4KB 大小的内存占用,所以,这种页表肯定是不会被选用的。

那真实的页表是长什么样的呢?

我们先要认识一下页框和页帧,页框是物理内存中被划分的最小单位,其大小是 4 KB,设置这个大小的原因是磁盘中的扇区大小通常是 512 字节,而 4 KB 就刚好是 8 个扇区的大小,这便于我们内存从磁盘中读取数据,还有一点是便于 OS 管理内存,因为假如在 4 GB 的内存下,页框此时为 1 KB ,那么此时 OS 要管理100 多万个( 4 GB / 1 KB )页表项,这不便于管理,而 4 KB 相较别的大小更适于 OS 管理,而页帧是磁盘中存储数据的最小单位,其大小也是 4 KB,原因也是磁盘中的扇区大小通常是 512 字节,而 4 KB 就刚好是 8 个扇区的大小,另外就是也刚好可以适配内存从磁盘中读取数据,因为都是 4 KB ,所以可以直接从磁盘中拿就可以了。

在 OS 中,其通过的是一个 32 位的空间,前面 10 位表示的是页目录中对应的页表项的位置,同时也是页目录的大小,通过这个页目录,可以查到该页目录中对应的页表项的位置,因为页表项可以有多个,所以需要一个页目录来定位是哪一个页表项,而页表项则是用于寻找虚拟地址空间中的数据在物理内存中的位置,这里的 10 也是页表项的大小,可以指向最多 2^10 个物理内存地址,当我们在内存中找到了对应的页框,此时,我们便需要定位真正的数据在哪里了,这时候最后的 12 位就要发挥作用了,这 12 位表示的就是该页框中的偏移位置,2^12 可以表示 4096 个字节,这时候就相当于了 4 KB,刚好对应了页框的整体大小,然后找到对应的初始位置,通过检测这个数据为什么数据类型,再决定看之后的多少位的数据,这样就可以做到了页表的映射。

2、见见猪跑(线程)

我们先一起想一个问题,我们的线程既然被称为了轻量级进程,那其是不是跟进程除了上面的那种表面的关系外,是不是还有什么跟进程有关联的地方呢?

答案是有的,对于 OS 和程序员而言,是只认线程的概念的,但是 Linux 是基于 Unix 用 C 语言重写一遍的 OS,所以,其里面是具有轻量级进程( light weigth process, 简称: LWP )的概念的,由于在 OS 中,一切对象都是通过先描述,再组织的思路来管理内部的资源的,而这里的 LWP 是通过复用进程对应的代码而形成的全新的概念,所以,这使得提高了代码的复用率而且代码维护起来更方便,因为这就相当于只需要维护同一段代码,注意,windows 的话则是通过完完全全的重写一份新的代码来实现线程的效果,但是这却极大地增大了工作量,因为里面存在许多的操作处理,代码也不易维护。因此,Linux 中只有轻量级进程,没有真正意义上的线程,所以 Linux 也需要提供对应的接口来将 LWP 封装为线程,事实上,对 Linux 这个 OS 而言,其实它也不认识 LWP,但是我们又需要使用多线程库来进行开发工作,因此,我们需要手动地进行动态链接,否则可能会报错( Ubuntu 似乎是已经默认进行了对 pthread 的动态链接,所以,我们可以不需要主动地动态链接就能使用里面的功能)。

c++ 复制代码
-lpthread

注意:这个 pthread 库虽然 OS 不认识,但是当我们安装 Linux 的时候,其还是会自动地安装这个动态库,因为 Linux 本就是脱胎于 Unix 的,而且我们的开发中通常也需要多线程,所以这就是必备的。

上面我们已经见识了线程的基本样子了,下面我们就通过代码先来认识其样子。

c++ 复制代码
test.cc:

#include <iostream>
#include <pthread.h>
#include <unistd.h>

void* handler(void* args)
{
    while(1) 
    {
        std::cout << "thread stream" << std::endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, handler, nullptr);//基本的测试样例
    while(1)
    {
        std::cout << "main stream" << std::endl;
        sleep(1);
    }
    return 0;
}

makefile:
test_thread:test_thread.cc
	g++ -o $@ $^ -std=c++11 
.PHONY:clean
clean:
	rm -f test_thread
        
终端1:
wjy@VM-4-8-ubuntu:~/blog_thread$ ./test_thread 
main stream
thread stream
thread stream
main stream
thread stream
main stream
main stream
thread stream
    
终端2:
wjy@VM-4-8-ubuntu:~/blog_thread$ ps -aL|head -1 && ps -aL|grep test_thread
    PID     LWP TTY          TIME CMD
2328051 2328051 pts/0    00:00:00 test_thread
2328051 2328052 pts/0    00:00:00 test_thread

这里运行了 test_thread 的程序后,在终端 2 中,我们可以看见这两程序的 PID 都相同,但是 LWP 不同,所以,这里我们也可以知道线程是在一个进程中的一个执行流,不同的编号代表的就是其是不同的线程。

进程本质上其实就是资源分配的主体,因为 CPU 运行都是以线程为单位,而非进程,进程得到的资源,最后都是由线程之间进行分配。

线程本质上就是 CPU 调度的基本单位,因为 CPU 运行都是直接通过线程来运行的。

这里先简单的见识一下这些代码,下面我来介绍一下线程对应的优点和缺点。

线程优点

1、创建线程所需要的成本比创建一个进程的成本更低。(因为线程只需要再构建一个 PCB 就行了,而进程除了构建 PCB 外,由于写时拷贝还需要构建新的页表和虚拟地址空间)

2、线程切换所需要的工作量比进程切换所需要的工作量更小。( CPU 只需要将 PCB 切换和保存当前的代码运行的上下文,而进程切换,还需要将进程的页表和虚拟地址空间进行切换)

补充:CPU 中有一个 cache 的缓存器,其功能就是将内存中的资源拉到这个缓存器中,然后寄存器再向这个缓存器中读取对应的资源,在现代计算机中,存在一个概念:局部性原理,就是指的通常访问内存中的一份数据的时候,其旁边的数据也很容易被访问到,所以,缓存器会将内存中的资源先保存在其内部,然后寄存器再来直接访问缓存器来获取数据,假如缓存器里没有对应的资源,那么缓存器再向内存中获取数据,而 cache 这个缓存器中会保存当前对应的热点数据,热点数据指的是被多次访问的数据,有了这个特性后,CPU 和内存之间的访问频率会降低,通过冯诺依曼体系结构,我们知道 CPU 运行最快,所以这也就提高了代码运行的效率。所以,CPU 进行线程切换的话,cache 不需要进行重新刷新,因为这些线程都是在同一个虚拟地址空间中的,代码区、数据区的数据是共享的,但是假如 CPU 进行进程切换的话,CPU 中 cache 的数据肯定需要重新加载,那么此时 cache 就没有发挥出其对应的功能。

3、线程占用的资源比进程占用的资源少。(因为线程只是 CPU 调度的载体,而资源全都是由进程所申请获得的)

4、线程可以并发式地执行多个任务。(因为存在多个执行流,所以进程内部也可以运行多个线程)

5、对于计算密集型应用,可以将计算任务分配给不同的线程来一起完成。

6、对于 I/O 密集型应用,可以同时对多个 I/O 任务分配给多个线程一起来完成。

线程缺点

CPU 是由 2 个主要模块组成的:一个是运算器,一个是控制器。现代计算机中的多核 CPU 指的就是有多个运算器。

有了优点,那肯定存在缺点。

1、性能损失

​ 当线程数量超过了 CPU 的核的数量,此时,就会导致 CPU 的性能下降,因为这时还需要进行相关的线程切换之类的操作。

2、健壮性降低

​ 因为线程间的代码区和数据区都是相同的,假如此时对在该区域的数据进行修改,可能就会使某一个线程的结果发生改变。

c++ 复制代码
test_thread.cc:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
    
int cnt = 0;

void* handler(void* args)
{
    while(1) 
    {
        std::cout << "thread stream: " << cnt++ << std::endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, handler, nullptr);//基本的测试样例
    while(1)
    {
        std::cout << "main stream: " << cnt << std::endl;
        sleep(1);
    }
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_thread$ ./test_thread 
main stream: 0
thread stream: 0
main stream: 1
thread stream: 1
thread stream: 2
main stream: 3
thread stream: 3
main stream: 4
thread stream: 4
main stream: 5
thread stream: 5
main stream: 6
thread stream: 6
main stream: 7
thread stream: 7
main stream: 8

在上面这个例子,我们就能看到线程导致的健壮性降低。

3、缺乏访问控制

​ 进程是访问控制的基本粒度,由于线程可以直接调用系统调用,从而可能会对整个进程造成影响(例如:kill)

线程虽然有共享的区域,但是其内部也有一些私有的数据:

独立的栈结构(是在进程的栈区分配出来的)

独立的上下文

私有的 PCB 属性
公共的区域(代码区,数据区):

文件描述符表

每种信号的处理方式

用户 id 和组 id

线程控制

既然已经知道了线程的基本内容,那么接下来就是线程的基本操作了。

pthread_create():

函数本体:

c 复制代码
int pthread_create(pthread_t *restrict thread,
                          const pthread_attr_t *restrict attr,
                          void *(*start_routine)(void *),
                          void *restrict arg);

第一个参数 pthread_t *restrict thread 是一个输出型参数,用于将新建的线程的地址输出到这个 thread 中。

第二个参数设置该线程的环境参数,如:优先级。但是由于我们不知道 OS 中其他线程的默认的环境参数,而且能调的参数幅度也很有限,所以正常就将其设置为nullptr。

函数描述:

csharp 复制代码
The  pthread_create()  function starts a new thread in the calling process.  The new thread starts execution by invoking start_routine(); arg is passed as the sole argument of start_routine().

翻译: pthread_create() 函数会在使用该函数的进程中新建一个线程。 新线程通过调用 start_routine() 开始执行;arg 作为 start_routine() 的唯一参数传递。

函数返回值:

vbnet 复制代码
On success, pthread_create() returns 0; on error, it returns an error number, and the contents of *thread are undefined.

翻译: 成功就返回 0,失败就返回对应的错误码,并且输出到 thread 参数中的内容是未定义的。

下面我们可以再来重新用一下这个函数。

c++ 复制代码
test_thread.cc:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
    

void* handler(void* args)
{
    while(1) 
    {
        std::cout << "thread stream" << std::endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, handler, nullptr);//基本的测试样例
    while(1)
    {
        std::cout << "main stream" << std::endl;
        sleep(1);
    }
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_thread$ ./test_thread 
main stream
thread stream
main stream
thread stream
main stream
thread stream
main streamthread stream

从上面的最后输出的一句话,我们就能知道:线程调度是随机的,没有一定明确的先后顺序。

当然我们也可以把变量 tid (就是函数的第一个参数 thread )的内容输出出来。

c++ 复制代码
std::cout << "main stream thread id: " << tid << std::endl;//想要得到其地址的话,可以使用 printf("main stream thread id: 0x%lx\n", pid);
//%x 就是让数字以 16 进制输出,由于这里输出的 thread id
终端:
wjy@VM-4-8-ubuntu:~/blog_thread$ ./test_thread 
main stream thread id: 134782539269824
thread stream
main stream thread id: 134782539269824
thread stream
main stream thread id: thread stream
134782539269824
main stream thread id: 134782539269824
thread stream
main stream thread id: thread stream134782539269824

当然,还有一个函数也可以来认识一下:pthread_self(),可以获得线程自身的 id

c++ 复制代码
test_thread.cc:

#include <iostream>
#include <cstdio>
#include <pthread.h>
#include <unistd.h>
    

void* handler(void* args)
{
    pthread_t tid = pthread_self();
    while(1) 
    {
        // std::cout << "thread stream id: " << tid << std::endl;
        printf("thread stream id: 0x%lx\n", tid);
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, handler, nullptr);//基本的测试样例
    while(1)
    {
        // std::cout << "main stream thread id: " << pid << std::endl;
        printf("main stream-child thread id: 0x%lx\n", tid);
        sleep(1);
    }
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_thread$ ./test_thread 
main stream-child thread id: 0x7eaa162006c0
thread stream id: 0x7eaa162006c0
main stream-child thread id: 0x7eaa162006c0
thread stream id: 0x7eaa162006c0
main stream-child thread id: 0x7eaa162006c0
thread stream id: 0x7eaa162006c0
thread stream id: 0x7eaa162006c0
main stream-child thread id: 0x7eaa162006c0
thread stream id: 0x7eaa162006c0
main stream-child thread id: 0x7eaa162006c0

这里,我们就能清楚地知道了 pthread_self() 这个函数的功能了。

浅聊一下 pthread_t 这个在 OS 中,本质上就是 unsigned long 类型

c 复制代码
typedef unsigned long int pthread_t;

但其真正表达的是一个地址,能够找到当前线程的 pthread 动态库,为什么要有这样的动态库呢?

我们知道在 OS 中对于内容的管理都是先描述,再组织,一个线程中有类似自己的栈结构,信号屏蔽字,线程 id 的内容,肯定需要一个载体来保存这些内容,而 pthread 动态库就是来完成这项工作的。

虚拟地址空间中有一块区域为 mmap ,也就是共享区,其内部放着各种数据与真实物理内存中文件的映射关系。

而 pthread_t 就是放的该线程在 mmap 中的位置,到时候可以借助这个地址来查看该线程的数据。 上面我们介绍完了创建线程的方法,下面我们继续介绍等待线程的方法。

scss 复制代码
pthread_join():

函数本身:

c 复制代码
int pthread_join(pthread_t thread, void **retval);

第一个参数是让我们父线程去等待哪个子线程。

第二个参数是一个输出型。

描述:

arduino 复制代码
The pthread_join() function waits for the thread specified by thread to terminate.  If that thread has already terminated, then pthread_join() returns immediately.  The thread specified by thread must be joinable.

翻译: pthread_join() 函数等待 thread 指定的线程终止。如果该线程已终止,则 pthread_join() 立即返回。thread 指定的线程必须是可联接的。 这里的这个可连接其实就是线程与其主线程还有关系,主线程还需要继续去等待该线程结束。创建一个线程的默认状态就是可连接状态(joinable)

返回值:

vbscript 复制代码
On success, pthread_join() returns 0; on error, it returns an error number.

翻译: 成功,返回 0,失败就返回错误码。

小用例:

c++ 复制代码
test_thread.cc:

#include <iostream>
#include <pthread.h>

void* handler(void* args)
{
    pthread_t tid = pthread_self();
    return (void *)"hello world";
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, handler, nullptr);//基本的测试样例
    void* ret;
    pthread_join(tid, &ret);
    std::cout << (const char *)ret << std::endl;
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_thread$ ./test_thread 
hello world

那么,为什么需要等待呢?

因为线程运行结束后,其对应的资源空间没有被释放,所以需要等待线程结束后,然后再对这份资源释放。

上面我提到了线程的连接状态,下面我来介绍一下如何将线程设置为分离状态,那么此时,父线程将不会去关注子线程的状态,最后也将不会由父线程去回收子线程的空间资源,而是由 OS 来完成这项工作。

scss 复制代码
pthread_detach():

函数本身:

c 复制代码
int pthread_detach(pthread_t thread);

注意:这个分离状态可以是由父线程来完成的,也可以是由子线程来完成的。

这就像现实中的分家,当我们从小孩长大后,我们的父母或许会要求我们自主独立生活,然后分家了,也可以是我们自己主动地要求跟自己父母分家。

这里的唯一的参数就是我们要分离的线程(这个参数就是上面 pthread_create 中的第一个输出型参数)。

描述:

vbnet 复制代码
The pthread_detach() function marks the thread identified by thread as detached.  When a detached thread terminates, its resources are automatically released back to the system without the need for another thread to join with the terminated thread.

翻译:

pthread_detach() 函数将线程标识的线程标记为 detached。当分离的线程终止时,其资源会自动释放回系统,而无需另一个线程与终止的线程联接。

这个标记也是在对应的 pthread 动态库中会记录。

返回值:

vbnet 复制代码
On success, pthread_detach() returns 0; on error, it returns an error number.

翻译:

成功则返回 0,失败则返回错误码。

小用例

c++ 复制代码
test_thread.cc:
#include <iostream>
#include <cassert>
#include <pthread.h>

void* handler(void* args)
{
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, handler, nullptr);//基本的测试样例
    pthread_detach(tid);
    int n = pthread_join(tid, nullptr);
    assert(n == 0);
    (void)n;
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_thread$ ./test_thread 
test_thread: test_thread.cc:23: int main(): Assertion `n == 0' failed.
Aborted

//方法二:
#include <iostream>
#include <cassert>
#include <pthread.h>
#include <unistd.h>
    
void* handler(void* args)
{
    pthread_detach(pthread_self());
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, handler, nullptr);//基本的测试样例
	sleep(3);
    int n = pthread_join(tid, nullptr);
    assert(n == 0);
    (void)n;//这里用对n进行void强转的原因是assert在release版本中,在预处理中会被删掉,此时,n就没有进行任何操作,那么,编译过程的时候会发生警告,使用void进行强转的话,就可以让n进行一个操作,而且这个操作不会给整体编译带来任何的影响,并且可以解除警告。
    return 0;
}

从上面对 pthread_join() 的了解,我们知道其成功的返回值为 0,但是这里却报错了,说明此时线程等待失败了,说明此时这两线程之间已经不存在任何关系了。

scss 复制代码
pthread_exit():

函数本身:

c 复制代码
void pthread_exit(void *retval);

由于这个函数的功能是令线程退出,所以其没有设置返回值,但是其第一个参数是一个输出型参数,可以用来自定义一个返回值。

描述:

arduino 复制代码
The  pthread_exit()  function  terminates the calling thread and returns a value via retval that (if the thread is joinable) is available to another thread in the same process that calls pthread_join(3).

翻译:

pthread_exit() 函数终止调用线程并通过 retval 返回一个值,该值(如果线程是可联接的)可供调用 pthread_join(3) 的同一进程中的另一个线程使用。

小用例:

c++ 复制代码
test_thread.cc:

#include <iostream>
#include <cassert>
#include <pthread.h>

void* handler(void* args)
{
    pthread_exit((void*)"hello world");
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, handler, nullptr);//基本的测试样例
    void * ret;
    pthread_join(tid, &ret);
    std::cout << (const char*)ret << std::endl;
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_thread$ ./test_thread 
hello world

这里就是用 pthread_exit() 和 pthread_join() 进行一个联动使用,这样就可以得到进程的返回值。

封装一个自己的线程库

了解了这些基本操作,下面就需要我们自己将其封装起来,让其有面向对象的一种思想。

c++ 复制代码
#pragma once

#include <iostream>
#include <functional>
#include <string>
#include <cassert>
#include <cstdio>
#include <pthread.h>

#define NAME_NUM 1024

class Thread;//先声明这个类

class Context/*用于包装线程的信息*/
{
public:
    Context():this_(nullptr), args_(nullptr){ }
public:
    Thread* this_;
    void* args_;//参数
};

class Thread/*封装一个自己的线程库*/
{
public:
    using func_t = std::function<void*(void*)>;//重命名函数类型
public:
    Thread(func_t func, void* args = nullptr, int num = 0):func_(func), args_(args), num_(num)
    {
        char buffer[NAME_NUM];
        snprintf(buffer, sizeof buffer, "thread-> %d", num);
        name_ = buffer;
        //创建进程后,自动进行对应的操作
        Context* ctx = new Context();
        ctx->this_ = this;
        ctx->args_ = args_;
        int n = pthread_create(&tid_, nullptr, start_routine, ctx);
        assert(n == 0);
        (void)n;
    }

 private:

    static void* start_routine(void* args)//默认自带了一个this指针,所以该函数的类型为 void*(*)(void*,this):不是void*(*)(void*),不符合要求
    {
        Context* ctx = static_cast<Context*>(args);
        return ctx->this_->run(ctx->args_);
        delete ctx;
        //return func();//err:静态方法只能调用静态成员
    }

    void* run(void* args)//运行函数,用于包装func_
    {
        return func_(args);
    }

public:   
    int join()//线程等待
    {
        int n = pthread_join(tid_, nullptr);
        assert(n == 0);
        return n;
    }
    
    pthread_t gettid()
    {
        return tid_;
    }

    ~Thread()
    {
        //do nothing
    }

private:
    pthread_t tid_;//线程id
    std::string name_;//线程名字
    int num_;//线程编号
    void* args_;//参数
    func_t func_;//子线程进行的操作
};

注意:类中的静态成员是属于整一个类的,静态方法只能调用静态成员,动态方法可以调用静态成员。

线程互斥

上面已经了解到了线程的健壮性不好,容易造成线程不安全。例如:假如一个线程的代码还没有运行完,但是此时发生了线程切换,这时候新的这个线程对数据进行访问进行判断之类的动作,然后导致了这个线程直接判断错误,然后造成了错误,这样就是导致了线程不安全的问题。

下面这个例子可以很好的看出这个问题。

c++ 复制代码
test_thread.cc:

#include <iostream>
#include <string>
#include <vector>
#include <unistd.h>
#include <pthread.h>

int tickets = 1000;

class ThreadData//用于存放线程数据的类
{
public:
    ThreadData(){
        char namebuffer[64];
        snprintf(namebuffer, sizeof namebuffer, "thread %d", _num);
        _name = namebuffer;
        _num++;
    }
    pthread_t & get_tid()
    {
        return _tid;
    }
    const std::string get_name()
    {
        return _name;
    }
private:
    std::string _name;
    static int _num;
    pthread_t _tid;
};

int ThreadData::_num = 1;

void* get_tickets(void* args)
{
    ThreadData* td = static_cast<ThreadData*> (args);
    while(true)
    {
        if(tickets > 0) std::cout << td->get_name() << " get tickets: " << tickets-- << std::endl;
        else break;
        usleep(100);//这是为了将线程设置为休眠状态,这样 CPU 才会进行线程切换操作
    }
    return nullptr;
}

int main()
{
    std::vector<ThreadData*> tp;
    for(int i = 0; i < 4; i++)
    {
        ThreadData* td = new ThreadData();
        tp.push_back(td);
        pthread_create(&tp[i]->get_tid(), nullptr, get_tickets, tp[i]);
    }
    for(auto it: tp)
    {
        pthread_join(it->get_tid(), nullptr);
    }
    for(auto &it:tp)
    {
        delete it;
    }
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_thread$ ./test_thread 
thread 3 get tickets: 1000
thread 2 get tickets: 999
thread 4 get tickets: 998
thread 2 get tickets: 997
thread 1 get tickets: 996
......
thread 2 get tickets: 6
thread 3 get tickets: 5
thread 1 get tickets: 4
thread 4 get tickets: 3
thread 2 get tickets: 2
thread 4 get tickets: 1
thread 1 get tickets: 0
thread 3 get tickets: -1

在这里就能看见票数甚至跑到了负数,这很明显发生了错误了,但是单看代码本身是没有存在什么逻辑上的错误的,那么这个错误是从哪里出来的呢?

首先先一起来认识一点,当我们的代码在发生汇编这一步的时候,我们的一句代码,最少要被转化为3条汇编语句:

1、从内存中读取数据

2、在 CPU 对数据进行处理

3、将数据重新写入内存中

但是线程执行是并发的,假如,此时的 tickets 此时已经是 1 了,当线程 1 才执行完第一条汇编语句的时候,可能此时发生了线程切换,然后将当前线程的上下文保存起来,然后下个线程也是如此,到最后又回到了线程 1,此时,tickets 可能已经被减为了 -1,但是,该线程还是认为这个数据是 1,然后继续执行之前的上下文,对这个 tickets 进行 -- ,然后就出现了上面的最后明明已经小于0了但是仍然向后执行的结果。

所以,就有了一个 锁 / 互斥量 的概念,这是使操作变成原子性的一种策略。

互斥量:可以用来保证只有一个执行流进入临界区中的一个变量。

原子性:对于操作而言,只有 2 种状态,要么完成,要么没动,不存在做到一半的概念。

见见猪跑(互斥量)

下面我就用互斥量对上面的代码的临界区进行修改一下。

c++ 复制代码
test_thread.cc:

#include <iostream>
#include <string>
#include <vector>
#include <memory>
#include <cassert>
#include <unistd.h>
#include <pthread.h>

int tickets = 1000;

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;//对互斥量的初始化

class ThreadData
{
public:
    ThreadData(){
        char namebuffer[64];
        snprintf(namebuffer, sizeof namebuffer, "thread %d", _num);
        _name = namebuffer;
        _num++;
    }
    pthread_t & get_tid()
    {
        return _tid;
    }
    const std::string get_name()
    {
        return _name;
    }
private:
    std::string _name;
    static int _num;
    pthread_t _tid;
};

int ThreadData::_num = 1;

void* get_tickets(void* args)
{
    ThreadData* td = static_cast<ThreadData*> (args);
    while(true)
    {
        pthread_mutex_lock(&mtx);
        if(tickets > 0)
        {
            std::cout << td->get_name() << " get tickets: " << tickets-- << std::endl;
            pthread_mutex_unlock(&mtx);
        }
        else
        {
            pthread_mutex_unlock(&mtx);
            break;
        }
        usleep(100);
    }
    usleep(100);
    return nullptr;
}

int main()
{
    std::vector<ThreadData*> tp;
    for(int i = 0; i < 4; i++)
    {
        ThreadData* td = new ThreadData();
        tp.push_back(td);
        pthread_create(&tp[i]->get_tid(), nullptr, get_tickets, tp[i]);//基本的测试样例
    }
    for(auto it: tp)
    {
        pthread_join(it->get_tid(), nullptr);
    }
    for(auto &it:tp)
    {
        delete it;
    }
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_thread$ ./test_thread
thread 4 get tickets: 1000
thread 1 get tickets: 999
thread 4 get tickets: 998
thread 3 get tickets: 997
thread 2 get tickets: 996
thread 1 get tickets: 995   
......
thread 4 get tickets: 7
thread 1 get tickets: 6
thread 4 get tickets: 5
thread 3 get tickets: 4
thread 2 get tickets: 3
thread 1 get tickets: 2
thread 2 get tickets: 1

此时,代码就能正常的执行了,也不会出现上面 tickets 明明已经小于 0 了,但是仍然向后执行的问题了。

互斥量基本操作

下面介绍一下关于互斥量的基本操作。

scss 复制代码
pthread_mutex_init():

函数本身

c 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
           const pthread_mutexattr_t *restrict attr);

第一个参数就是我们想要初始化的互斥量。

第二个参数就是我们想对这个互斥量的环境参数。正常设置为 nullptr 即可,理由跟上面的 pthread_create() 的第二个参数一样。

返回值:

成功返回 0,失败返回错误码。

功能:

对互斥量进行初始化。

假如这个互斥量是一个全局变量,那么我们也可以这样做:互斥量 = PTHREAD_MUTEX_INITIALIZER。这样也是对互斥量进行初始化。

c 复制代码
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
scss 复制代码
pthread_mutex_lock():

函数本身:

c 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);

参数只有一个,这个就是我们需要上锁的互斥量。

功能

对互斥量上锁,对于这块临界区只有拥有这个锁的线程才能进行访问,假如此时别的线程获取了这个锁,目前在申请这份锁的就会被阻塞挂起,直到这个锁被释放,然后,在申请这份锁的线程将会对这份锁进行争夺,争夺能力强的线程将会得到这份锁。

返回值

成功则返回 0,失败则返回错误码。

scss 复制代码
pthread_mutex_unlock():

函数本身

c 复制代码
int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数只有一个,这个就是我们需要解锁的互斥量。

返回值

成功则返回 0,失败则返回错误码。

上面既然有了初始化,那么肯定也会存在销毁。

scss 复制代码
pthread_mutex_destroy():

函数本身:

c 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

这个唯一的参数就是当我们 进程 / 线程 结束的时候需要销毁的互斥量。

功能:

销毁互斥量。

返回值:

成功返回 0,失败返回错误码。

封装一个自己的互斥量

既然我们知道了这个互斥量的基本的接口,我们也能自己将其封装起来,实现一个 RAII 性的互斥锁。

c++ 复制代码
Mutex.hpp:

#pragma once

#include <iostream>
#include <pthread.h>

//RAII性质的互斥量
class Mutex
{
public:
    Mutex(pthread_mutex_t* mutex = nullptr):mutex_(mutex) { }
    void lock()
    {
        if(mutex_) pthread_mutex_lock(mutex_);
    }
    void unlock()
    {
        if(mutex_) pthread_mutex_unlock(mutex_);
    }
    ~Mutex()
    {
        //nothing
    }
private:
    pthread_mutex_t* mutex_;
};

class LockGaurd
{
public:
    LockGaurd(pthread_mutex_t* mtx):mutex_(mtx)
    {
        mutex_.lock();
    }
    ~LockGaurd()
    {
        mutex_.unlock();
    }
private:
    Mutex mutex_;
};

这个锁就像 C++ 11 新增的智能指针一样,当跳出目前的作用域的时候,会自动解锁,这样就能防止使用者忘记了解锁,从而最后可能导致死锁的问题。

用自己封装的互斥量对上面取票代码再进行修改。

c++ 复制代码
test_thread.cc:

#include "Mutex.hpp"
#include <iostream>
#include <string>
#include <vector>
#include <memory>
#include <cassert>
#include <unistd.h>
#include <pthread.h>

int tickets = 1000;

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

class ThreadData
{
public:
    ThreadData(){
        char namebuffer[64];
        snprintf(namebuffer, sizeof namebuffer, "thread %d", _num);
        _name = namebuffer;
        _num++;
    }
    pthread_t & get_tid()
    {
        return _tid;
    }
    const std::string get_name()
    {
        return _name;
    }
private:
    std::string _name;
    static int _num;
    pthread_t _tid;
};

int ThreadData::_num = 1;

void* get_tickets(void* args)
{
    ThreadData* td = static_cast<ThreadData*> (args);
    while(true)
    {
        LockGaurd lck(&mtx);//here
        if(tickets > 0)
        {
            std::cout << td->get_name() << " get tickets: " << tickets-- << std::endl;
        }
        else
        {
            break;
        }
        usleep(100);
    }
    usleep(100);
    return nullptr;
}

int main()
{
    std::vector<ThreadData*> tp;
    for(int i = 0; i < 4; i++)
    {
        ThreadData* td = new ThreadData();
        tp.push_back(td);
        pthread_create(&tp[i]->get_tid(), nullptr, get_tickets, tp[i]);//基本的测试样例
    }
    for(auto it: tp)
    {
        pthread_join(it->get_tid(), nullptr);
    }
    for(auto &it:tp)
    {
        delete it;
    }
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_thread$ ./test_thread
thread 4 get tickets: 1000
thread 1 get tickets: 999
thread 4 get tickets: 998
thread 3 get tickets: 997
thread 2 get tickets: 996
thread 1 get tickets: 995   
......
thread 4 get tickets: 7
thread 1 get tickets: 6
thread 4 get tickets: 5
thread 3 get tickets: 4
thread 2 get tickets: 3
thread 1 get tickets: 2
thread 2 get tickets: 1

这样就让互斥量有了 RAII 性质,提高码字效率。

当然在上面,有人可能就已经发现了,使用了锁之后效率降低了,这是为什么呢?

因为当有线程得到锁后,其余线程再去申请锁的话,这时候这些申请锁的线程会进入阻塞状态,然后再进行线程切换,直到切换到拿到锁的线程运行到解锁状态,所以中间的线程切换的部分导致了整体代码运行的效率降低了。

互斥锁原理

下面就再来探究互斥锁的原理。

互斥锁在汇编里是通过 swap / exchange 操作来完成的。

上面是理论居多,下面我用一个更直观的方式来讲解。

在讲之前,我们要有一个认知:

1、CPU 中只有一套寄存器;

2、寄存器中的上下文是属于 CPU 内当前线程的。

最开始的时候,线程 1 先申请的锁,CPU 中的 %al 寄存器会被放入一个 0,然后再跟内存中的 mutex 中的 1 进行交换,这样就得到 %al 寄存器中的数据为 1,而 mutex 的数据为 0。

这时候发生了线程切换,由于线程切换后,线程的上下文也会被保存带走,所以,CPU 中 %al 寄存器又被设置为了 0,然后因为内存中的 mutex 因为被线程 1 申请走了,所以里面的内容就是 0,0 和 0交换后,还是 0,所以,线程 2 会被挂起等待。

线程切换之后,会把之前线程执行的上下文重新加载到 CPU 的寄存器中,当然包含了 %al 寄存器,所以此时 %al 寄存器的内容就是 1,当然解锁的过程不需要管这个,之后再申请锁的时候,还是会把这里 %al 寄存器的内容设置为 0,所以解锁的时候,不管这里是 1 还是 0,解锁只需要将内存中的 mutex 的内容设置为 1 就可以了,然后再将之前申请锁的线程全都唤醒,然后这些线程就会开始争夺这一把锁。

上面这些就是互斥锁的一个原理,说到底,互斥锁本质就是一个交换的过程。

可重入函数和线程安全

可重入函数概念:不同执行流进入同一函数后,这个函数的结果没有出现任何问题,就称这个函数为可重入函数,否则就是不可重入函数。

**线程安全概念:**多个执行流运行同一段代码,最终结果不会出现异常,就称之为线程安全。通常情况下,临界资源在没有被锁保护的情况下,被多个执行流同时访问操作,就会出现线程不安全的情况。

线程

下面我们先来见一见线程不安全的常见情况:

常见的线程不安全情况:

1、不保护共享变量的函数。(最开始的取票程序,因为没有保护 tickets 这个临界资源而导致最后的结果出错)

2、函数状态随着被调用,状态发生变化的函数。(取票程序,因为判断条件使用的是 tickets 这个全局变量,这个全局变量一直在发生改变,而且其是作为该取票函数的判断条件)

(PS:函数状态指的就是函数在执行过程所关联的一组数据或者条件)

3、返回指向静态变量指针的函数。(因为这可以对这个静态变量进行修改)

4、调用线程不安全的函数。

那么,既然我们知道了线程不安全的情况,那下面就应该介绍一下如何保证线程安全:

1、每个线程对全局变量和静态变量只设置只读权限,不允许写入。(因为线程不安全正常是由临界资源被访问修改而导致的,修改后的取票程序就是如此)

2、类或对应的接口操作是原子性的。(这可以避免运行结果的二义性产生,修改后的取票程序就是这样的)

3、多个线程之间的切换,运行结果不会产生二义性。(修改后的取票程序就是避免产生二义性)

函数

下面先来介绍一下常见函数不可重入的情况:

1、调用了 malloc / free 函数,因为 malloc 函数是用全局链表来管理堆的。(对于多个执行流同时对链表最终造成了什么影响感兴趣的可以翻一下我上一篇博客最后的可重入函数)

2、调用了标准 I / O 库函数,标准 I / O 库的很多实现都以不可重入的方式使用全局数据结构。

3、可重入函数体内调用了静态数据结构。

然后就是可重入函数的常见情况:

1、不使用全局变量或静态变量。

2、不使用用 malloc / new 开辟出来的新空间。

3、不调用不可重入函数。

4、不返回静态数据或全局数据,所有数据都由函数的调用者提供。

5、使用本地数据,或者对全局数据进行本地拷贝,从而之后对这个拷贝的操作不会对这个全局数据造成影响。

可重入函数和线程安全的关联

联系:函数是可重入的,那么线程就是安全的,但是函数假如是不可重入的,那么可能就会引发线程不安全;假如一个函数中有全局变量,那么此时这个函数是不可重入的,线程也是不安全的。

**区别:**可重入函数是线程安全的一种表现形式;但是线程安全不一定是可重入的( 因为线程安全可能是通过使用锁而达成的线程安全,所以此时不可能有多个执行流同时运行同一段代码,此时线程安全就不是可重入的),可是可重入函数则一定线程安全( 数学上,线程安全是可重入函数的必要条件 );如果对临界资源进行上锁,则这个线程是安全,但是假如函数对已经上过锁的临界资源再一次上锁,那么,此时就会产生 死锁 的问题,此时线程就是不安全的。

这个两者的大致关系图:

当然,这里也顺便讲一下不可重入函数和线程不安全的关系。

不可重入函数不一定是线程不安全的,是这样的:不可重入函数是针对函数的概念,其关注的是函数被中断后,然后再次进入该函数,结果错误的情况;

线程不安全则是针对线程的概念,其关注的是多个线程运行同一段代码,最终运行结果错误的情况。不可重入函数假如在多线程环境下能用锁之类的方式保护这个函数,那么这个线程就是安全的。

线程不安全也不一定是不可重入函数,理由是这样的:在线程不安全的条件下,假如不会影响函数在中断后再次进入该函数运行后的结果的话,那么这个函数也仍然是可重入函数。

这两者的大致关系图:

死锁

还是那句话,想了解一项东西,那首先得知道这是个什么东西,那么死锁是什么呢?

**死锁概念:**死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放资源而处于一种永久等待的状态。

产生死锁的条件

死锁是不可能平白无故就产生的,产生死锁肯定需要一定的条件。

1、互斥条件:一个资源只能由一个执行流使用。这就相当于当一件东西在我手上的时候,不想这件东西被别人拿掉。

2、请求与保持条件:一个执行流因请求资源而阻塞,并对已经获得的资源不释放。用个小栗子就明白了:小明和小红每个人都有 5 毛钱,他们一起到了一家便利店,这个便利店有个 1 块钱的棒棒糖,此时,小明向小红索要她手上的 5 毛钱用来买棒棒糖,此时,小明手上的 5 毛钱肯定也不愿意拿出去。

3、不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺。这就相当于有一件公共物品,别人已经先借了这个公共物品,我不能在别人还在用的时候就跑过去伸手直接拿,只能等这个人用完了,才能去拿。

4、 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。书接上回,小红此时也肯定不愿意借给小明,并对小明反问:"为什么不能是你借个我?",然后这两个人就一直僵持着,直到天荒地老。

**注意:**一把锁也可以造成死锁,对同一个把锁进行上 2 次锁,此时这把锁就是一个死锁了。

解决死锁的方法

那么既然知道了产生死锁的条件,那么解决方案也就油然而生了,毕竟只有这些条件都满足的情况下,死锁才会产生,那么我们只需要破坏其中的一个条件,那么死锁就不会产生。

分析:

首先,互斥条件是锁的天生属性,那么破坏这个条件是不可能的一件事情,那么请求与保持条件呢?这个可以,因为这个是由程序员我们所操作决定的。然后就是不剥夺条件也是锁的天然属性,所以也不可能破坏这个条件。最后循环等待条件也是由程序员我们所操作决定的。

结论:

那么,我们就要往破坏 请求与保持条件 和 循环等待条件 的方向上走。

那么,得到的解决理论方法有 3 种:

1、加锁顺序一致。(本质:破坏循环等待条件)

2、避免锁未释放的场景。(当然这是最直白的想法,只要申请锁的时候,这个锁还是在的,那么就一定不会产生锁)

3、资源一次性分配。(本质:破坏请求与保持条件,正常要跟加锁顺序一致一起使用,否则也可能出现死锁)

通过上面的这 3 种理论方法而产生的真实避免死锁的算法:

1、死锁检测算法

2、银行家算法

这里就不介绍这 2 种算法了。

注意:

虽然这里介绍了解决方法,但假如有一种方法可以不使用锁的方法,那就尽量使用不用锁的方法,因为假如使用锁后产生了 bug,那很难修复。

因此,能尽量不用锁就不用,除非已经没有别的方法了,才使用锁。

线程同步

在讲线程同步之前,我先讲一个小故事:在某一所学校里面,在这个学校的图书馆里设置了一个 vip 自习室,这个自习室只能由拿到锁的人对这个自习室进行支配,而这把锁就放在一个固定区域,有一天,小明早上 4 点起床,来到了图书馆,他上来就把钥匙拿了,直接进到了这间 vip 自习室,然后在里面把门又锁上了,然后他一直自习到早上 6 点,他感觉有点饿了,于是他就出来了,此时,外面早就在等待这把钥匙了,他刚把钥匙放回原来的位置处,但是他转念一想:"我今天起来这么早拿这把钥匙,假如我现在就走了,是不是有点亏了?",由于钥匙离他最近,所以他又把钥匙拿了回来,又重新进入了这间 vip 自习室里面,刚学不到 1 分钟,但是他又感觉有点饿,然后就这样以此反复半小时,可能学习了就不到 5 分钟,此时,这种行为很明显就会引起在外面等待这把钥匙的学生们的欲望,但是又无可奈何。在线程的角度上,这些学生最后的状态叫做 饥饿问题

那么为了解决这个问题,线程里提出了一个新的概念:线程同步

同步概念:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题。

书接上回,此时由于学生对小明的这种行为强烈反对,然后学校对这个 vip 自习室出了一个新规定:从 vip 自习室出来后要求一定要把钥匙返回规定的位置,然后重新排队去等待这把钥匙。之后有一天小明又是早上 4 点起床去图书馆拿 vip 自习室的钥匙进到 vip 自习室里,然后又把这间自习室从里面锁了,然后他一直自习到早上 6 点,她感觉有点饿了,于是他就出来了,把钥匙放了回去,但是他又感觉有点亏了,他刚想去重新那这把钥匙就被旁边的老师制止了,并被要求去排队等待。这就是线程同步。

线程除了同步,还有竞态的概念:因为时序问题,而导致程序异常,我们称之为竞态条件。在多线程环境下,这种现象还是非常好理解的。

而为了满足这种同步,Linux 的线程库种引入了 条件变量 的概念。这个条件变量就是用于让一堆线程在某一个条件下一起等待,当满足该条件时,这些线程再去争夺这份临界资源。

这里的条件变量我也可以用一个小栗子来让大家理解一下:

在现实生活中,过去10年的时候,大型互联网公司会存在一个专门的招聘团队,专门用于出差到全国各个高校去招聘人员,通常的做法是先找到当地的一家不错的酒店 / 宾馆,然后把一层包下来了,然后每个房间都配置了一个 HR,当学生们已经做完笔试后,就会要求他们去面试。当然,为了保证面试的质量,HR 一次只会面一个学生。此时有一家公司的组织能力比较差,现场面试的环境非常混乱,出现了人挤人的感觉,当一个学生刚面完,出了房间,然后就又在门口等待,等到 HR 出来通知其余学生进行面试的时候,由于这个学生离门最近,然后又进去进行面试了,这时候可能就会出现一个学生被面多次的情况。

但是这时候有一个非常厉害的 HR,要求所有面试的学生都要在一个特定的区域内进行等待,并说明了他只从这个区域内选学生进去面试,然后学生们就开始排队去等。

当然 POSIX 中没有明确规定的唤醒线程的方式,而且不同的 OS 和不同的线程库唤醒线程的方式都可能有不同的实现方式,但肯定不会是直接按照等待队列来唤醒线程的,但是这里为了更好地理解,所以这里说的是排队等待。

当然,在代码编写中,一个条件变量下可能有多个线程,而不是这里的单个线程,但是为了更好理解这里就画的一个。

条件变量的基本操作

那么,现在我们的理论性东西应该都已经比较清楚了,此时,就应该开始学习一些对这条件变量的基本的操作了。

首先是条件变量的初始化:

scss 复制代码
pthread_cond_init():

函数本身:

c 复制代码
int pthread_cond_init(pthread_cond_t *restrict cond,
           const pthread_condattr_t *restrict attr);

第一个传入的参数就是目标条件变量。其数据类型是 pthread_cond_t *,是一个 pthread_cond_t 的一个指针,我们也可以来看下其类型的原型。

c 复制代码
typedef union
{
  struct __pthread_cond_s __data;
  char __size[__SIZEOF_PTHREAD_COND_T];
  __extension__ long long int __align;
} pthread_cond_t;

这个就是该数据类型的原本的样子,这里清楚地可以看到它本质上是一个联合体,内部包含了条件变量对应的各种数据。

内部使用联合体的原因:

1、节省空间:因为联合体的大小相对来说是固定的,所以这就避免了所有可能的实现方式都分配内存空间。

2、兼容性更好:通过使用联合体,可以实现在不同环境下根据自身特点来完成 pthread_cond_t 。

3、灵活性更高:便于之后的维护和扩展,后面只需要在这个联合体中增加新的内容,而且也不需要在分配内存空间。

第二个传入的参数跟之前的线程中的环境参数和互斥量的环境参数一样,是关于条件变量的环境参数,正常也是将其设置为 nullptr。

功能:

对条件变量进行初始化。和上面的对互斥量进行初始化的含义是相同的。

返回值:

成功返回 0,失败返回错误码。

有了初始化的操作,那必然会有一个销毁用的函数:

scss 复制代码
pthread_cond_destroy():

函数本身:

c 复制代码
int pthread_cond_destroy(pthread_cond_t *cond);

传入的参数就是我们要销毁的条件变量。

功能:

销毁传入的条件变量。

返回值:

成功返回 0,失败返回错误码。

然后就是在条件变量下进行等待临界资源的函数,也就是上面所描述的,到一个特定区域等待的功能:

scss 复制代码
pthread_cond_wait():

函数本身:

c 复制代码
int pthread_cond_wait(pthread_cond_t *restrict cond,
           pthread_mutex_t *restrict mutex);

第一个传入的参数是要在哪个条件变量下进行等待资源。

第二个传入的参数是保护该临界资源的锁。这个为什么要传入后面的例子中会讲到,这里就不解释了。

功能:

将调用这个函数的线程设置为阻塞状态对临界资源进行等待,在没有被唤醒的情况下,该线程会一直等待。

返回值:

成功返回 0,失败返回错误码。

还有一种等待方式:

c 复制代码
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
           pthread_mutex_t *restrict mutex,
           const struct timespec *restrict abstime);

这里面相比 pthread_cond_wait 多了一个参数 abstime,就是指的一个关于时间的结构体。

功能是在一定时间内进行等待临界资源,假如时间到了,有资源和被唤醒,那么,该函数返回 0,然后继续向下执行;假如超时了,但是没有被唤醒,此时该函数就会返回 ETIMEDOUT 错误码。

进行等待资源的线程处于阻塞状态后肯定需要某个方式来被唤醒,也就是上面的 HR 来这个特定的等待区叫人面试的意思相同:

scss 复制代码
pthread_cond_signal():

函数本身:

c 复制代码
int pthread_cond_signal(pthread_cond_t *cond);

传入的参数是一个条件变量的地址,指的是唤醒在这个条件变量下的某个线程。

功能:

将在 cond 下的线程中的某一个进行唤醒。

还有一种唤醒方式:

c 复制代码
int pthread_cond_broadcast(pthread_cond_t *cond);

这个唤醒线程方式跟上面的 pthread_cond_signal 的方式是不一样的,这里的唤醒方式就像最后描述的 broadcast 一样,是一个广播,可以让所有在该条件变量下的线程都唤醒,然后让这些线程再去争夺这个临界资源,当然争夺的时候最后的判断是用的 while,防止出现 虚假唤醒 的情况发生。这里用上面的栗子来讲的话,就是由于这个团队的时间可能不太够了,所以就让所有要面试的学生都一次性进来面试。(更形象点的话,就是 HR 让在等待区的学生一起争夺最后的面试机会)

补充:

虚假唤醒大白话来说指的就是条件变量可能在没有满足的情况下,继续向后执行代码,最后导致代码运行结果出错。

生产消费模型------基于阻塞队列的生产消费模型

在现实生活中,我们买东西通常都是在超市里,我们就是消费者,但是,超市其实也不是真正提供商品的,而是相当于一个大型仓库,可以将货物进行存储起来,而给超市提供商品的,就是供货商,供货商就是生产者,这样做的好处就是可以做到生产者和消费者之间进行解耦,因为我们知道供货商他们生产商品的时候是需要启动机器的,有的机器开始运行的这段过程是非常费劲的,可能需要消耗大量的电力,然后又要对这些机器进行调试,让它们进入一个工作的状态,然后才能开始生产商品,假如没有超市这样的中间媒介,那么,生产者和消费者之间是处于一种强耦合的状态,这时候我们生产者可能每隔一两天去找供货商去给我们临时生产一包薯片,那么,最终结果就是供货商入不敷出,最后倒闭了。

生产消费模型的性质

那么就在这里我们就可以总结出生产消费模型的优点了。

1、解耦:有了超市的这个中间媒介,生产者和消费者之间发生了解耦。

2、并发:超市中的商品肯定不止有单单一个商品,所以支持多个供货商可以同时生产商品;消费者也肯定不止有一个,一个超市里面肯定支持多个消费者来消费。

3、支持忙闲不均:因为超市里面肯定有一个用于存储商品的仓库,当哪件商品少了,超市工作人员就能去这个仓库里面把商品重新补上去,此时假如消费者的消费能力比较强的话,超市就可以从仓库中拿出商品供消费者继续消费一段时间;假如消费者的消费能力比较弱的话,那么供货商提供的多的一定量的商品也可以被保存到超市的仓库里。

当然这个生产消费模型有一个快速记忆的方法:321 原则。

3 指的是 3 种关系:

生产者之间:互斥关系。用上面的栗子来讲就是,一包火腿肠里面,不允许出现两个牌子的火腿肠吧。

消费者之间:互斥关系。当小明想买最后一个棒棒糖的时候,小红也想买,这时候他们两个之间就是处于一种互斥关系。

生产者和消费者之间:互斥和同步关系。当超市工作人员在放商品的时候,消费者不可能直接从工作人员手中拿吧,肯定要等工作人员放完之后,消费者才能拿。

2 指的是 2 种角色:

生产者:生产任务的。将商品生产出来。

消费者:完成任务的。将商品消费掉。

1 指的是 1 个缓冲区:

缓冲区:用于存放任务的地方。上面的超市就是一个缓冲区。

基于阻塞队列的生产消费模型的实现

那么,现在我们知道了生产消费模型的基本性质,基于这些性质,我们可以实现一个基于阻塞队列的简单的生产消费模型。

c++ 复制代码
BlockQueue.hpp:

#pragma once

#include <iostream>
#include <pthread.h>
#include <queue>

static const int gmax = 5;

template <class T>
class BlockQueue//一个简单生产消费模型
{
public:
    BlockQueue(int max = gmax):max_(max)
    {
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&p_cond_, nullptr);
        pthread_cond_init(&c_cond_, nullptr);
    }
    void push(const T& d)//对于输入参数用 const &
    {
        pthread_mutex_lock(&mutex_);
        //使用while,可以避免虚假唤醒,因为当线程被唤醒的时候,可能并没有真正的满足条件变量,这时候可能会导致错误
        while(is_max())
        {
            //细节:当调用pthread_cond_wait时候,会先将互斥锁释放,然后线程进入阻塞队列中
            //当该函数调用结束时,会将互斥锁再拿回来,这也就是为什么我们需要把mutex放入该函数的原因
            //而且该mutex还得必须是我们使用的
            pthread_cond_wait(&p_cond_, &mutex_);//缓冲区已经满了,此时生产者进入阻塞队列中等待
        }
        q.push(d);//将数据推给缓冲区
        pthread_mutex_unlock(&mutex_);
        pthread_cond_signal(&c_cond_);//此时缓冲区中至少有一个数据,消费者就可以开始消费了
    }
    void pop(T* out)//对于输出参数用 * //对于输入输出参数用 &
    {
        pthread_mutex_lock(&mutex_);
        while(is_empty())
        {
            pthread_cond_wait(&c_cond_, &mutex_);
        }
        *out = q.front();//消费者从缓冲区读取数据
        q.pop();
        pthread_mutex_unlock(&mutex_);
        pthread_cond_signal(&p_cond_);//因为消费者从缓冲区拿了一个数据,所以缓冲区中至少有一个空位
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&p_cond_);
        pthread_cond_destroy(&c_cond_);
    }
private:
    bool is_empty()
    {
        return q.empty();
    }
    bool is_max()
    {
        return q.size() == max_;
    }
private:
    int max_;//该阻塞队列的最大值
    std::queue<T> q;//阻塞队列
    pthread_mutex_t mutex_;//互斥锁
    pthread_cond_t p_cond_;//生产者条件变量
    pthread_cond_t c_cond_;//消费者条件变量
};


Task.hpp:

#pragma once

#include <iostream>
#include <functional>
#include <cstdio>

//计算任务
class CalTask
{
private:
    using func_t = std::function<int(int, int, char)>;
public:
    CalTask(){}
    CalTask(int x, int y, func_t func, char opt):x_(x), y_(y), func_(func), opt_(opt) {}
    std::string operator ()()
    {
        int ret = func_(x_, y_, opt_);
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = %d", x_, opt_, y_, ret);
        return buffer;
    } 
    std::string toTaskString()
    {
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = ?", x_, opt_, y_);
        return buffer;
    }
private:
    func_t func_;
    int x_;
    int y_;
    char opt_;
};

//运算符合集
std::string math = "+-*/%";

//计算函数
int mycal(int x, int y, char opt)
{
    int result;
    switch(opt)
    {
    case '+':
        result = x+y;
        break;
    case '-':
        result = x-y;
        break;
    case '*':
        result = x*y;
        break;
    case '/':
    {
        if(!y)
        {
            std::cerr << "div zero!" << std::endl;
            result = -1;
        }        
        else 
        result = x/y;
    }
        break;
    case '%':
    {
        if(!y)
        {
            std::cerr << "mod zero!" << std::endl;
            result = -1;
        }        
        else 
        result = x%y;
    }
        break;
    default:
        break;
    }
    return result;
}


MainCP.cpp:

#include "BlockQueue.hpp"
#include "Task.hpp"
#include <ctime>

template<class C>
class block_queues
{
public:
    block_queues(){}
    block_queues(BlockQueue<C>* c):c_(c){}
public:
    BlockQueue<C>* c_;
};

void* consumer(void* bq)//消费者
{
    BlockQueue<CalTask>* c_bq_ = static_cast<block_queues<CalTask, SaveTask> *>(bq)->c_;
    while(true)
    {
        CalTask ct;
        c_bq_->pop(&ct);
        std::string result = ct();
        std::cout << "消费者消费:" << result << std::endl; 
    }
    return nullptr;
}

void* producter(void* bq)//生产者
{
    BlockQueue<CalTask>* c_bq_ = static_cast<block_queues<CalTask, SaveTask> *>(bq)->c_;
    while(true)
    {
        int x = rand() % 10;
        int y = rand() % 5;
        char opt = math[rand() % math.size()];
        CalTask t(x, y, mycal, opt);
        c_bq_->push(t);
        std::cout << "生产者生产: " << t.toTaskString() << std::endl;
        // sleep(1);//假如想让生成任务的节奏慢一点就可以使用这个函数
    }
    return nullptr;
}

int main()
{
    srand((unsigned int)time(nullptr));

    block_queues<CalTask> bqs;
    bqs.c_ = new BlockQueue<CalTask>();

    pthread_t c, p;
    pthread_create(&p, nullptr, producter, (void*)&bqs);
    pthread_create(&c, nullptr, consumer, (void*)&bqs);

    pthread_join(p, nullptr);
    pthread_join(c, nullptr);

    delete bqs.c_;

    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/test_thread/test_CP/my_queue/BlockQueue$ ./MainCP
生产者生产: 9 / 2 = ?
生产者生产: 9 / 1 = ?
生产者生产: 6 + 3 = ?
生产者生产: 7 - 3 = ?
生产者生产: 4 % 0 = ?
生产者生产: 7 - 1 = ?
消费者消费:9 / 2 = 4
消费者消费:9 / 1 = 9
消费者消费:6 + 3 = 9
生产者生产: 2 - 2 = ?
生产者生产: 4 - 2 = ?
消费者消费:7 - 3 = 4
mod zero!
消费者消费:4 % 0 = -1
消费者消费:7 - 1 = 6
消费者消费:2 - 2 = 0
消费者消费:4 - 2 = 2

注意一下上面这串代码的一些小细节:

1、在正常写代码的过程中,输入型参数是 const & ,输出型参数是 指针 ,输入输出型参数是 & 的。

2、上面的对于是否为满的判断使用的是 while ,这是为了防止出现虚假唤醒的情况,因为对于不同线程情况会有所不同,所以需要用 while 来重复判断。

3、当调用 pthread_cond_wait 时候,会先将互斥锁释放,然后线程进入阻塞队列中,当该函数调用结束时,会将互斥锁再拿回来,这也就是为什么我们需要把 mutex 放入该函数的原因,而且该 mutex 还得必须是当前正在使用的。

这里就实现了一个单生产者单消费者的代码,当然想要多生产者和多消费者就直接多创建几个线程就可以了。

这里我们是将任务直接输出到屏幕上,那么我们是否可以让这些任务生成日志呢?

答案是可以的,方法也非常的简单,只需要我们在现在的基础上,向后再加一个阻塞队列,然后,然后增加新的保存任务就可以了。

c++ 复制代码
BlockQueue.hpp:

#pragma once

#include <iostream>
#include <pthread.h>
#include <queue>

static const int gmax = 5;

template <class T>
class BlockQueue//一个简单生产消费模型
{
public:
    BlockQueue(int max = gmax):max_(max)
    {
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&p_cond_, nullptr);
        pthread_cond_init(&c_cond_, nullptr);
    }
    void push(const T& d)//对于输入参数用 const &
    {
        pthread_mutex_lock(&mutex_);
        //使用while,可以避免虚假唤醒,因为当线程被唤醒的时候,可能并没有真正的满足条件变量,这时候可能会导致错误
        while(is_max())
        {
            //细节:当调用pthread_cond_wait时候,会先将互斥锁释放,然后线程进入阻塞队列中
            //当该函数调用结束时,会将互斥锁再拿回来,这也就是为什么我们需要把mutex放入该函数的原因
            //而且该mutex还得必须是我们使用的
            pthread_cond_wait(&p_cond_, &mutex_);//缓冲区已经满了,此时生产者进入阻塞队列中等待
        }
        q.push(d);//将数据推给缓冲区
        pthread_mutex_unlock(&mutex_);
        pthread_cond_signal(&c_cond_);//此时缓冲区中至少有一个数据,消费者就可以开始消费了
    }
    void pop(T* out)//对于输出参数用 * //对于输入输出参数用 &
    {
        pthread_mutex_lock(&mutex_);
        while(is_empty())
        {
            pthread_cond_wait(&c_cond_, &mutex_);
        }
        *out = q.front();//消费者从缓冲区读取数据
        q.pop();
        pthread_mutex_unlock(&mutex_);
        pthread_cond_signal(&p_cond_);//因为消费者从缓冲区拿了一个数据,所以缓冲区中至少有一个空位
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&p_cond_);
        pthread_cond_destroy(&c_cond_);
    }
private:
    bool is_empty()
    {
        return q.empty();
    }
    bool is_max()
    {
        return q.size() == max_;
    }
private:
    int max_;//该阻塞队列的最大值
    std::queue<T> q;//阻塞队列
    pthread_mutex_t mutex_;//互斥锁
    pthread_cond_t p_cond_;//生产者条件变量
    pthread_cond_t c_cond_;//消费者条件变量
};


Task.hpp:

#pragma once

#include <iostream>
#include <functional>
#include <cstdio>

class SaveTask
{
public:
    SaveTask(){}
    SaveTask(std::string message):message_(message){}
    std::string get_message()
    {
        return message_;
    }
private:
    std::string message_;
};

class CalTask
{
private:
    using func_t = std::function<int(int, int, char)>;
public:
    CalTask(){}
    CalTask(int x, int y, func_t func, char opt):x_(x), y_(y), func_(func), opt_(opt) {}
    std::string operator ()()
    {
        int ret = func_(x_, y_, opt_);
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = %d", x_, opt_, y_, ret);
        return buffer;
    } 
    std::string toTaskString()
    {
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = ?", x_, opt_, y_);
        return buffer;
    }
private:
    func_t func_;
    int x_;
    int y_;
    char opt_;
};

std::string math = "+-*/%";

int mycal(int x, int y, char opt)
{
    int result;
    switch(opt)
    {
    case '+':
        result = x+y;
        break;
    case '-':
        result = x-y;
        break;
    case '*':
        result = x*y;
        break;
    case '/':
    {
        if(!y)
        {
            std::cerr << "div zero!" << std::endl;
            result = -1;
        }        
        else 
        result = x/y;
    }
        break;
    case '%':
    {
        if(!y)
        {
            std::cerr << "mod zero!" << std::endl;
            result = -1;
        }        
        else 
        result = x%y;
    }
        break;
    default:
        break;
    }
    return result;
}


MainCP.cc:

#include "BlockQueue.hpp"
#include "Task.hpp"
#include <ctime>


template<class C, class S>
class block_queues
{
public:
    block_queues(){}
    block_queues(BlockQueue<C>* c, BlockQueue<S>* s):c_(c), s_(s){}
public:
    BlockQueue<C>* c_;
    BlockQueue<S>* s_; 
};

void* consumer(void* bq)//消费者
{
    BlockQueue<CalTask>* c_bq_ = static_cast<block_queues<CalTask, SaveTask> *>(bq)->c_;
    BlockQueue<SaveTask>* s_bq_ = static_cast<block_queues<CalTask, SaveTask> *>(bq)->s_;
    while(true)
    {
        CalTask ct;
        c_bq_->pop(&ct);
        std::string result = ct();
        std::cout << "消费者消费:" << result << std::endl; 

        SaveTask st(result);
        
        s_bq_->push(st);
    }
    return nullptr;
}

void* producter(void* bq)//生产者
{
    BlockQueue<CalTask>* c_bq_ = static_cast<block_queues<CalTask, SaveTask> *>(bq)->c_;
    while(true)
    {
        int x = rand() % 10;
        int y = rand() % 5;
        char opt = math[rand() % math.size()];
        CalTask t(x, y, mycal, opt);
        c_bq_->push(t);
        std::cout << "生产者生产: " << t.toTaskString() << std::endl;
    }
    return nullptr;
}

void* saver(void* bq)//存储者
{
    BlockQueue<SaveTask>* s_bq_ = static_cast<block_queues<CalTask, SaveTask> *>(bq)->s_;
    while(true)
    {
        std::string path = "./log.txt";
        FILE* fp = fopen(path.c_str(), "a+");
        SaveTask st;
        s_bq_->pop(&st);
        std::string message = st.get_message();
        fwrite(message.c_str(), sizeof(char), message.size(),fp);
        fputs("\n",fp);
        std::cout << "存储者存储......" << std::endl;
        fclose(fp);
    }
    return nullptr;
}

int main()
{
    srand((unsigned int)time(nullptr));

    block_queues<CalTask, SaveTask> bqs;
    bqs.c_ = new BlockQueue<CalTask>();
    bqs.s_ = new BlockQueue<SaveTask>();

    pthread_t c, p, s;
    pthread_create(&p, nullptr, producter, (void*)&bqs);
    pthread_create(&c, nullptr, consumer, (void*)&bqs);
    pthread_create(&s, nullptr, saver, (void*)&bqs);

    pthread_join(p, nullptr);
    pthread_join(c, nullptr);
    pthread_join(s, nullptr);

    delete bqs.c_;
    delete bqs.s_;

    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/test_thread/test_CP/my_queue/BlockQueue$ ./MainCP
3 * 1 = 3
7 - 1 = 6
8 / 4 = 2
5 - 0 = 5
5 * 1 = 5
2 % 1 = 0
3 / 1 = 3
2 * 1 = 2
3 / 3 = 1
5 % 4 = 1
5 + 2 = 7
2 / 2 = 1
1 * 0 = 0
9 % 2 = 1
7 * 3 = 21
4 + 2 = 6
wjy@VM-4-8-ubuntu:~/test_thread/test_CP/my_queue/BlockQueue$ cat log.txt
3 * 1 = 3
7 - 1 = 6
8 / 4 = 2
5 - 0 = 5
5 * 1 = 5
2 % 1 = 0
3 / 1 = 3
2 * 1 = 2
3 / 3 = 1
5 % 4 = 1
5 + 2 = 7
2 / 2 = 1
1 * 0 = 0
9 % 2 = 1
7 * 3 = 21
4 + 2 = 6

这样就成功地让这些任务生成对应的日志了。

此时可能会有人有疑问:明明阻塞队列里放任务和取任务都是只能允许其中的一个进行,那生产消费模型的高效是体现在哪里呢?

确实,放任务和取任务的过程都是阻塞的,在这里高效型确实无法体现,但是我们整个生产消费模型的整个流程是:

生成任务 -> 放任务到缓冲区 -> 线程从缓冲区中取任务 -> 线程执行任务

上面讲到的中间的两个过程确实不是高效的,但是生成任务和线程执行任务确实可以并发式的,这两端的过程是高效的,假如线程执行的任务是一个长时任务,那么线程并发式地执行任务相比串行地执行任务一定是高效的;假如外部生成任务的时间比较长,那么此时并发式地生成任务,则可以提高对应的生成任务的效率。

信号量

理解信号量

之前我们就了解过信号量本质就是一个计数器,用于记录当前临界资源的多少。

这就相当于现实中购买电影票,我们看电影之前都需要买电影票,而我们购买电影票这种行为本质上就是预订机制,当电影票还有余的时候,我们就直接通过电影票这种行为对影院里面的座位进行预定,这样就能保证之后我们来看电影的时候一定有一个属于自己的座位,而我们买电影票的行为从操作系统的角度来看就是向信号量申请临界资源。

操作信号量

对信号量的操作被称为 PV 原语,是由 2 部分组成的:P,V。

P :向临界区申请资源,假如申请成功就拥有了对 1 份临界资源的使用权,也就是购买电影票成功的话,就有了对影院的座位的使用权,此时,由于临界区里的临界资源减少了 1 份,那么此时对应的信号量就应该 -1;失败的话,则该线程被挂起,直到有一个线程将临界资源释放,此时,线程才能申请成功也才能继续向下执行代码。

V :向临界区释放申请到的临界资源,由于临界区的临界资源增加了 1 份,那么此时的信号量就应该 +1。

注意:

对于信号量的操作,线程只有完整地执行了 P 和 V 才能算对信号量进行了操作。因为假如只进行了 P 操作,那么,此时线程将一直占有这份临界资源,那么,别的线程也无法申请得到这份临界资源,那么,此时,线程就不能够被称为对信号量进行了操作。

scss 复制代码
sem_init():

函数本身:

c 复制代码
int sem_init(sem_t *sem, int pshared, unsigned int value);

第一个参数 sem 指的就是用户初始化的信号量。

c 复制代码
typedef union
{
  char __size[__SIZEOF_SEM_T];
  long int __align;
} sem_t;

这个就是 sem_t 的定义。也是联合体,至于为什么是联合体上面已经解释过了,这里就不再解释了。

第二个参数 pshared 指的是状态,0 表示线程间共享,非 0 表示进程间共享。

第三个参数就是信号量的初始值。

功能:

对信号量进行初始化。

返回值

成功返回 0,失败返回 -1,并且错误码被设置。

scss 复制代码
sem_destroy():

函数本身:

c 复制代码
int sem_destroy(sem_t *sem);

传入的参数就是要销毁的信号量。

功能:

将信号量进行销毁。

返回值:

成功返回 0,失败返回 -1,并且错误码被设置。

scss 复制代码
sem_wait():

函数本身:

c 复制代码
int sem_wait(sem_t *sem);

这个就是 P 操作,参数就是用户想对哪个信号量进行 P 操作。

功能:

等待信号量,并将信号量的值 -1。

返回值:

成功返回 0,失败返回 -1,并且错误码被设置。

scss 复制代码
sem_post():

函数本身:

c 复制代码
int sem_post(sem_t *sem);

这个就是 V 操作,传入的参数就是yo能过户想对哪个信号量进行 V 操作。

功能:

释放信号量,并将信号量的值 +1。

返回值:

成功返回 0,失败返回 -1,并且错误码被设置。

生产消费模型------基于循环队列的生产消费模型

理论基础:

在将这个之前,我们先一起来看一下一个小故事:

小明和小红在玩一个小游戏,小明和小红从同一个位置出发,小明在这个桌子上的盘子上按顺序放苹果,小红要按顺序对每一个盘子上的苹果吃 2 口,但是这个游戏有规定:小明不能套小红一圈(因为从计算机的角度来看,多次对同一个位置写入数据会将旧的数据覆盖掉,套一圈代表消费者还没读到新的数据,这个新的数据就被覆盖了),小红不能超过小明(消费者超过生产者后,又在读取旧数据,所以不能超过)

这里我们就能看到基于循环队列的生产消费模型的一个雏形。我们过去在数据结构中,应该见过了循环队列的样子,我们可以通过一个数组对自己的数组长度进行取余,这样就可以保证这个队列成为了一个循环队列,这就是物理上不是循环的,但逻辑上是循环的。

基于循环队列的生产消费模型的实现

下面就开始实现这个基于循环队列的生产消费模型:

c++ 复制代码
RingQueue.hpp:

#pragma once

#include <iostream>
#include <pthread.h>
#include <semaphore.h>
#include <vector>

static const int gcap = 5;

template <class T>
class RingQueue
{
public:
    RingQueue(int cap = gcap):_ring_queue(cap), _cap(cap)
    {
        //初始化信号量
        //在最开始的时候,由于该循环队列上没有任何数据,所以,最开始一定是先向 _space_sem 申请的线程成功并向下继续运行对应的代码
        //所以,_space_sem 的值被初始化为 _cap,_data_sem 被初始化为0
        sem_init(&_space_sem, 0, _cap);
        sem_init(&_data_sem, 0, 0);
        //初始化位置
        _productor_step = 0;
        _consumer_step = 0;
        //初始化信号
        pthread_mutex_init(&_p_mutex, nullptr);
        pthread_mutex_init(&_c_mutex, nullptr);
    }
    void push(const T& in)//由生产者完成
    {
        //循环队列中,当消费者和生产者在同一位置的时候也不需要进行特殊处理
        //该两者在同一位置的时候,有两种情况:为空,为满
        //为空的时候,由于_data_sem为0,所以,只能由生产者先向下执行
        //为满的时候,由于_space_sem为0,所以,只能由消费者者先向下执行
        P(_space_sem);
        pthread_mutex_lock(&_p_mutex);
        _ring_queue[_productor_step++] = in;
        _productor_step %= _cap;
        pthread_mutex_unlock(&_p_mutex);
        V(_data_sem);
    }
    void pop(T* out)//由消费者完成
    {
        P(_data_sem);
        pthread_mutex_lock(&_c_mutex);
        *out = _ring_queue[_consumer_step++];
        _consumer_step %= _cap;
        pthread_mutex_unlock(&_c_mutex);
        V(_space_sem);
    }
    ~RingQueue()
    {
        //销毁信号量
        sem_destroy(&_space_sem);
        sem_destroy(&_data_sem);
        //销毁互斥锁
        pthread_mutex_destroy(&_p_mutex);
        pthread_mutex_destroy(&_c_mutex);
    }
private:
    void P(sem_t& sem)
    {
        sem_wait(&sem);
    }
    void V(sem_t& sem)
    {
        sem_post(&sem);
    }
private:
    std::vector<T> _ring_queue;//循环队列
    int _cap;//循环队列的最大容量
    //对于生产者和消费者来说,他们看重的临界资源不同
    //生产者看重的是空间资源,而消费者看重的数据资源,所以我们要设置两个不同的信号量:_space_sem, _data_sem
    sem_t _space_sem;//空间信号量
    sem_t _data_sem;//数据信号量
    int _productor_step;//生产者的位置
    int _consumer_step;//消费者的位置
    pthread_mutex_t _p_mutex;//生产者的互斥锁
    pthread_mutex_t _c_mutex;//消费者的互斥锁
};

test.cc:

#include "RingQueue.hpp"
#include <cstdlib>
#include <ctime>
#include <unistd.h>v
#include <sys/types.h>

void* productor(void* rq_)
{
    RingQueue<int>* rq = static_cast<RingQueue<int>*> (rq_);
    while(true)
    {
        int data = rand() % 10;
        rq->push(data);
        std::cout << "生产者生产数据: " << data << std::endl;
    }
    return nullptr;
}

void* consumer(void* rq_)
{
    RingQueue<int>* rq = static_cast<RingQueue<int>*> (rq_);
    while(true)
    {
        int data;
        rq->pop(&data);
        std::cout << "消费者消费数据: " << data << std::endl;
    }
    return nullptr;
}

int main()
{
    pid_t id = getpid();
    srand((unsigned int)time(nullptr) ^ 0x123135 ^ id);
    RingQueue<int>* rq = new RingQueue<int> ();
    pthread_t p, c;
    pthread_create(&p, nullptr, productor, (void*)rq);
    pthread_create(&c, nullptr, consumer, (void*)rq);
    
    pthread_join(p, nullptr);
    pthread_join(c, nullptr);

    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/test_thread/test_CP/my_queue/RingQueue$ ./test
生产者生产数据: 9
生产者生产数据: 9
生产者生产数据: 1
消费者消费数据: 9
消费者消费数据: 1
生产者生产数据: 1
生产者生产数据: 5
生产者生产数据: 9
生产者生产数据: 6
生产者生产数据: 1
消费者消费数据: 1
消费者消费数据: 5
消费者消费数据: 9
消费者消费数据: 6
消费者消费数据: 1
消费者消费数据: 1

注意:虽然上面对小明和小红有了要求 / 生产者和消费者之间有了要求,但是这里却不需要进行特殊处理。因为,该两者在同一位置的时候,有两种情况:为空,为满:为空的时候,由于_data_sem 为0,所以,只能由生产者先向下执行;为满的时候,由于 _space_sem 为0,所以,只能由消费者者先向下执行。因此,不需要我们对此进行一个特殊处理。

上面我们只是写了一个基础的模型代码,下面我们可以向里面加任务。

c++ 复制代码
RingQueue.hpp:

#pragma once

#include <iostream>
#include <pthread.h>
#include <semaphore.h>
#include <vector>

static const int gcap = 5;

template <class T>
class RingQueue
{
public:
    RingQueue(int cap = gcap):_ring_queue(cap), _cap(cap)
    {
        //初始化信号量
        //在最开始的时候,由于该循环队列上没有任何数据,所以,最开始一定是先向 _space_sem 申请的线程成功并向下继续运行对应的代码
        //所以,_space_sem 的值被初始化为 _cap,_data_sem 被初始化为0
        sem_init(&_space_sem, 0, _cap);
        sem_init(&_data_sem, 0, 0);
        //初始化位置
        _productor_step = 0;
        _consumer_step = 0;
        //初始化信号
        pthread_mutex_init(&_p_mutex, nullptr);
        pthread_mutex_init(&_c_mutex, nullptr);
    }
    void push(const T& in)//由生产者完成
    {
        //循环队列中,当消费者和生产者在同一位置的时候也不需要进行特殊处理
        //该两者在同一位置的时候,有两种情况:为空,为满
        //为空的时候,由于_data_sem为0,所以,只能由生产者先向下执行
        //为满的时候,由于_space_sem为0,所以,只能由消费者者先向下执行
        P(_space_sem);
        pthread_mutex_lock(&_p_mutex);
        _ring_queue[_productor_step++] = in;
        _productor_step %= _cap;
        pthread_mutex_unlock(&_p_mutex);
        V(_data_sem);
    }
    void pop(T* out)//由消费者完成
    {
        P(_data_sem);
        pthread_mutex_lock(&_c_mutex);
        *out = _ring_queue[_consumer_step++];
        _consumer_step %= _cap;
        pthread_mutex_unlock(&_c_mutex);
        V(_space_sem);
    }
    ~RingQueue()
    {
        //销毁信号量
        sem_destroy(&_space_sem);
        sem_destroy(&_data_sem);
        //销毁互斥锁
        pthread_mutex_destroy(&_p_mutex);
        pthread_mutex_destroy(&_c_mutex);
    }
private:
    void P(sem_t& sem)
    {
        sem_wait(&sem);
    }
    void V(sem_t& sem)
    {
        sem_post(&sem);
    }
private:
    std::vector<T> _ring_queue;//循环队列
    int _cap;//循环队列的最大容量
    //对于生产者和消费者来说,他们看重的临界资源不同
    //生产者看重的是空间资源,而消费者看重的数据资源,所以我们要设置两个不同的信号量:_space_sem, _data_sem
    sem_t _space_sem;//空间信号量
    sem_t _data_sem;//数据信号量
    int _productor_step;//生产者的位置
    int _consumer_step;//消费者的位置
    pthread_mutex_t _p_mutex;//生产者的互斥锁
    pthread_mutex_t _c_mutex;//消费者的互斥锁
};

Task.hpp:

#pragma once

#include <iostream>
#include <functional>
#include <cstdio>

class CalTask
{
private:
    using func_t = std::function<int(int, int, char)>;
public:
    CalTask(){}
    CalTask(int x, int y, func_t func, char opt):x_(x), y_(y), func_(func), opt_(opt) {}
    std::string operator ()()
    {
        int ret = func_(x_, y_, opt_);
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = %d", x_, opt_, y_, ret);
        return buffer;
    } 
    std::string toTaskString()
    {
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = ?", x_, opt_, y_);
        return buffer;
    }
private:
    func_t func_;
    int x_;
    int y_;
    char opt_;
};

std::string math = "+-*/%";

int mycal(int x, int y, char opt)
{
    int result;
    switch(opt)
    {
    case '+':
        result = x+y;
        break;
    case '-':
        result = x-y;
        break;
    case '*':
        result = x*y;
        break;
    case '/':
    {
        if(!y)
        {
            std::cerr << "div zero!" << std::endl;
            result = -1;
        }        
        else 
        result = x/y;
    }
        break;
    case '%':
    {
        if(!y)
        {
            std::cerr << "mod zero!" << std::endl;
            result = -1;
        }        
        else 
        result = x%y;
    }
        break;
    default:
        break;
    }
    return result;
}

test.cc:

#include "RingQueue.hpp"
#include "Task.hpp"
#include <cstdlib>
#include <ctime>
#include <unistd.h>
#include <sys/types.h>

void* productor(void* rq_)
{
    RingQueue<CalTask>* rq = static_cast<RingQueue<CalTask>*> (rq_);
    while(true)
    {
        int x = rand() % 10;
        int y = rand() % 5;
        char op = math[rand() % math.size()];
        CalTask ct(x, y, mycal, op);
        rq->push(ct);
        std::cout << "生产者生产数据: " << ct.toTaskString() << std::endl;
    }
    return nullptr;
}

void* consumer(void* rq_)
{
    RingQueue<CalTask>* rq = static_cast<RingQueue<CalTask>*> (rq_);
    while(true)
    {
        CalTask ct;
        rq->pop(&ct);
        std::cout << "消费者消费数据: " << ct() << std::endl;
    }
    return nullptr;
}

int main()
{
    pid_t id = getpid();
    srand((unsigned int)time(nullptr) ^ 0x123135 ^ id);
    RingQueue<CalTask>* rq = new RingQueue<CalTask> ();
    pthread_t p, c;
    pthread_create(&p, nullptr, productor, (void*)rq);
    pthread_create(&c, nullptr, consumer, (void*)rq);
    
    pthread_join(p, nullptr);
    pthread_join(c, nullptr);

    return 0;
}


终端:
wjy@VM-4-8-ubuntu:~/test_thread/test_CP/my_queue/RingQueue$ ./test
生产者生产数据: 5 / 0 = ?
生产者生产数据: 3 % 3 = ?
生产者生产数据: 9 % 2 = ?
生产者生产数据: 3 * 2 = ?
生产者生产数据: 8 - 1 = ?
生产者生产数据: 5 % 1 = ?
消费者消费数据: 3 % 3 = 0
消费者消费数据: 9 % 2 = 1
消费者消费数据: 3 * 2 = 6
消费者消费数据: 8 - 1 = 7
消费者消费数据: 5 % 1 = 0
消费者消费数据: mod zero!
9 % 0 = -1

这里就可以把设置的任务通过生产消费模型里了,这里写的也是最简单的单生产者单消费者的模型,这里要加的话,直接在中间多创建几个线程来充当消费者和生产者就可以了。

线程池

在之前,已经见过了进程池的模样,进程池是通过一个池来管理拥有的进程,当有任务派发过来的时候,就让这个池去唤醒对应的进程完成对应的任务。这就是一种池化的技术,这种池化技术的思想:一个容器内包含大量的执行流,当外部有任务传进来后,这个容器能自动地让其中的某一个执行流去完成这个任务。现实生活中,也有很多地方用到了这种池化技术的思想:公司项目团队、马路上的共享单车、酒店房间......

而线程池也是使用了这种池化技术。在上面我们就了解到了线程过多会增大调度开销,会影响缓存的局部性(局部性原理)以及整体运行效率,而线程池维护着其内部的多个线程,等待着外部的任务并完成任务。这避免了在处理一些简单任务的时候创建线程和销毁线程的代价。但对于长时任务而言,线程池的优点的成效一般。

有人可能又会产生疑问:这个线程池和生产消费模型明明差不多,但是为什么这里说线程池对于长时任务的效率一般,而生产消费模型对长时任务高效呢?

因为这是站在了不同的角度来看问题的,对于线程池,我们主要看的是因为线程创建和销毁的开销较大,所以产生了线程池,而且有了线程池也想让它发挥出没有额外的开销的优点,因为少了这些开销,那么相较于其他需要这些开销的方法,它能在相同时间内完成更多个任务,所以希望它内部的线程能一直在做不同的任务,就像线程 a 现在做下载任务,过一会就做上传任务;而对于生产消费模型,我们主要是跟串行的执行任务的普通做法进行比较,因为跟串行的相比,生产消费模型就能很好地体现它的并发性。

应用场景

那么知道了其对应的优点,那也就可以知道了对应的应用场景:

1、需要大量执行线程来完成任务,并且完成任务的时间比较短。

2、对性能要求严苛的应用。(因为这里减少了创建线程时的开销,所以,其性能更高)

3、接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。(这主要担心的是内存可能在这段时间内到达极限,就是爆内存了)

线程池的简单示例

对于线程池的创建,这里的做法采用的是在一个线程池内先创建固定的线程,然后用线程池接受任务后,向线程发送任务,线程再自动地执行对应的任务。

注意:这里会使用上面我们自己封装好的线程库,互斥量。

c++ 复制代码
threadpool.hpp:

#pragma once

#include "Thread.hpp"
#include "Mutex.hpp"
#include <queue>//盛放任务的载体
#include <vector>//用于盛放线程的载体
#include <mutex>

static const int gtdnum = 3;

template<class T>
class ThreadPool;

template<class T>
class ThreadData//大号的结构体,用于盛放线程对应的数据
{
public:
    ThreadData(ThreadPool<T>* this_, std::string name_):_this(this_), _name(name_)
    {

    }
public:
    ThreadPool<T>* _this;
    std::string _name;
};

template<class T>
class ThreadPool
{
private:
    static void* handler(void* args)//静态成员函数无法访问动态成员变量,所以需要另一个类来保存
    {
        ThreadData<T>* td = static_cast<ThreadData<T>*> (args);
        while(true)
        {
            td->_this->queue_lock();
            while(td->_this->task_empty()) 
            {
                td->_this->thread_wait();
            }
            T t = td->_this->pop();
            td->_this->queue_unlock();
            std::cout << td->_name << "正在执行" << t.toTaskString() << " 并完成了该任务: " << t() << std::endl;//TODO
            t();
        }
        delete td;
        return nullptr;
    }
private:
    void queue_lock()
    {
        pthread_mutex_lock(&_mutex);
    }
    void queue_unlock()
    {
        pthread_mutex_unlock(&_mutex);
    }
    void thread_wait()
    {
        pthread_cond_wait(&_cond, &_mutex);
    }
    bool task_empty()
    {
        return _task_queue.empty();
    }
    T pop()
    {
        //不能加锁,因为该临界区已经上过一次锁了,这里再上锁,申请不到锁,最后造成死锁问题
        T t = _task_queue.front();
        _task_queue.pop();
        return t;
    }
public:
    ThreadPool(int thread_num = gtdnum):_thread_num(thread_num)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_cond, nullptr);
        for(int i = 0; i < _thread_num; i++)
        {
            _thread_pool.push_back(new Thread());
        }
    }
    void run()
    {
        for(auto &it :_thread_pool)
        {
            ThreadData<T>* td = new ThreadData<T>(this, it->getname());
            std::cout << it->getname() << " run......" << std::endl;
            it->start(handler, td);
        }
    }
    void push(const T& task)
    {
        LockGaurd lkg(&_mutex);
        pthread_cond_signal(&_cond);
        _task_queue.push(task);
    }
    
    ~ThreadPool()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_cond);
        for(auto &it :_thread_pool)
        {
            delete it;
        }
private:
    std::vector<Thread*> _thread_pool;//线程池
    std::queue<T> _task_queue;//任务队列
    pthread_mutex_t _mutex;//互斥锁
    pthread_cond_t _cond;//条件变量
    int _thread_num;//线程最大数量
};

Thread.hpp:

#pragma once

#include <iostream>
#include <functional>
#include <string>
#include <cassert>
#include <cstdio>
#include <pthread.h>

#define NAME_NUM 1024

class Thread;//先声明这个类

template <class T>
class Context/*用于包装线程的信息*/
{
public:
    Context():this_(nullptr), args_(nullptr){ }
    Context(T* _this, void* args = nullptr):this_(_this), args_(args){ }
public:
    T* this_;
    void* args_;//参数
};

class Thread/*封装一个自己的线程库*/
{
public:
    using func_t = std::function<void*(void*)>;//重命名函数类型

public:
    Thread()
    {
        char namebuffer[NAME_NUM];
        snprintf(namebuffer, sizeof namebuffer, "thread-> %d", num_++);
        name_ = namebuffer;
    }

    void start(func_t func, void* args = nullptr)
    {
        func_ = func;
        args_ = args;
        Context<Thread>* ctx = new Context<Thread>();
        ctx->this_ = this;
        ctx->args_ = args_;
        int n = pthread_create(&tid_, nullptr, start_routine, ctx);
        assert(n == 0);
        (void)n;
    }
 private:

    static void* start_routine(void* args)//默认自带了一个this指针,所以该函数的类型为 void*(*)(void*,this):不是void*(*)(void*),不符合要求
    {
        Context<Thread>* ctx = static_cast<Context<Thread>*>(args);
        void * ret = ctx->this_->run(ctx->args_);
        delete ctx;
        return ret;
        //return func();//err:静态方法只能调用静态成员
    }

    void* run(void* args)//运行函数,用于包装func_
    {
        return func_(args);
    }

public:   
    int join()//线程等待
    {
        int n = pthread_join(tid_, nullptr);
        assert(n == 0);
        return n;
    }
    
    pthread_t gettid()
    {
        return tid_;
    }

    std::string getname()
    {
        return name_;
    }

    ~Thread()
    {
        //do nothing
    }

private:
    pthread_t tid_;//线程id
    std::string name_;//线程名字
    static int num_;//线程编号
    void* args_;//参数
    func_t func_;//子线程进行的操作
};

int Thread::num_ = 1;

Mutex.hpp:

#pragma once

#include <iostream>
#include <pthread.h>

//RAII性质的互斥量
class Mutex
{
public:
    Mutex(pthread_mutex_t* mutex = nullptr):mutex_(mutex) { }
    void lock()
    {
        if(mutex_) pthread_mutex_lock(mutex_);
    }
    void unlock()
    {
        if(mutex_) pthread_mutex_unlock(mutex_);
    }
    ~Mutex()
    {
        //nothing
    }
private:
    pthread_mutex_t* mutex_;
};

class LockGaurd
{
public:
    LockGaurd(pthread_mutex_t* mtx):mutex_(mtx)
    {
        mutex_.lock();
    }
    ~LockGaurd()
    {
        mutex_.unlock();
    }
private:
    Mutex mutex_;
};

Task.hpp:

#pragma once

#include <iostream>
#include <functional>
#include <cstdio>

class CalTask
{
private:
    using func_t = std::function<int(int, int, char)>;
public:
    CalTask(){}
    CalTask(int x, int y, func_t func, char opt):x_(x), y_(y), func_(func), opt_(opt) {}
    std::string operator ()()
    {
        int ret = func_(x_, y_, opt_);
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = %d", x_, opt_, y_, ret);
        return buffer;
    } 
    std::string toTaskString()
    {
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = ?", x_, opt_, y_);
        return buffer;
    }
private:
    func_t func_;
    int x_;
    int y_;
    char opt_;
};

std::string math = "+-*/%";

int mycal(int x, int y, char opt)
{
    int result;
    switch(opt)
    {
    case '+':
        result = x+y;
        break;
    case '-':
        result = x-y;
        break;
    case '*':
        result = x*y;
        break;
    case '/':
    {
        if(!y)
        {
            std::cerr << "div zero!" << std::endl;
            result = -1;
        }        
        else 
        result = x/y;
    }
        break;
    case '%':
    {
        if(!y)
        {
            std::cerr << "mod zero!" << std::endl;
            result = -1;
        }        
        else 
        result = x%y;
    }
        break;
    default:
        break;
    }
    return result;
}

main.cc:

#include "threadpool.hpp"
#include "Task.hpp"
#include <memory>
#include <ctime>
#include <unistd.h>


int main()
{
    srand((unsigned int)time(nullptr) ^ getpid() ^ 0x2342143);
    std::unique_ptr<ThreadPool<CalTask>> tp(new ThreadPool<CalTask>());
    tp->run();
    while(true)
    {
        int x = rand()%10;
        int y = rand()%3;
        char op = math[rand()%math.size()];
        CalTask t(x, y, mycal, op);
        ThreadPool<CalTask>::getInstance()->push(t);
        sleep(1);   
    }
    return 0;
}
    
终端:
wjy@VM-4-8-ubuntu:~/test_thread/test_CP/ThreadPool$ ./thread_test 
thread-> 1 run......
thread-> 2 run......
thread-> 3 run......
thread-> 1正在执行9 * 1 = ? 并完成了该任务: 9 * 1 = 9
thread-> 1正在执行8 * 1 = ? 并完成了该任务: 8 * 1 = 8
thread-> 2正在执行0 % 1 = ? 并完成了该任务: 0 % 1 = 0
thread-> 3正在执行3 * 0 = ? 并完成了该任务: 3 * 0 = 0
thread-> 1正在执行9 % 2 = ? 并完成了该任务: 9 % 2 = 1
thread-> 2正在执行9 % 0 = ? 并完成了该任务: mod zero!
9 % 0 = -1
mod zero!
thread-> 3正在执行4 / 0 = ? 并完成了该任务: div zero!
4 / 0 = -1
div zero!

注意:

在线程池的 pop 函数中,我们不能对 pop 过程进行上锁,因为 pop 在被调用线程调用之前,线程已经上锁了,此时假如再对这个线程上锁的话,那么这个线程的锁就会变成死锁。

这样,我们就实现了一个基本的线程池。

线程安全的单例模式

首先来了解一下单例是指什么?

单例的概念:某些类,只应该具有一个对象,就称之为单例。

那么,为什么要有这样的设计模式呢?

现在的 IT 行业在世界爆火,有许多人都涌入了这个行业当中,俗话说林子大了,什么鸟都有。而高手和萌新之间的两极分化越来越严重,为了不让萌新们相对不太拖高手的后腿,于是高手对一些常见的情形提供了一些解决方案,这就是设计模式

饿汉模式

下面就来介绍两个经典的单例模式:饿汉模式,懒汉模式。

首先,饿汉模式拿我们现实生活举例的话就是吃完饭后就立马把碗洗了,然后吃下一顿饭的时候就直接使用这个碗就可以了。这也就是饿汉模式的核心思想。

下面就是饿汉模式的基础代码:

c++ 复制代码
template <typename T>
class Singleton{
public:
    static T* getInstance(){
        return &data;
    }
private:
    static T data;
}

这个类在程序运行的时候就有了一个对象。

懒汉模式

而懒汉模式用现实生活来举例就是吃完饭后不洗碗,直到下一次要吃饭的时候再来洗,洗完后直接使用。就是懒汉模式的核心思想:延时加载。这可以用于优化服务器的启动速度。

下面就是懒汉模式的基础代码:

c++ 复制代码
template <typename T>
class Singleton{
public:
    static T* getInstance(){
        if(inst == nullptr)
            inst = new T();
        return inst;
    }
private:
    static T* inst;
}

这里就是当我们用到了这个类的时候,我们才开始对这个类进行加载。

但是,这里的这个懒汉模式其实存在线程不安全的问题。因为这里的操作并不是原子的,可能有多个线程对这个代码执行,最终 inst 被多次 new 了,但是最后 inst 的空间取决最后一次,所以中间 new 了几次的空间就无法找到了,最终导致了一个内存泄漏的问题。所以,我们要让这个操作成为一个原子的,所以,我们需要用到锁,但是单用锁又有点影响效率,因为别的线程都要最后都会处于一个阻塞的状态,然后每个线程又要重新地申请锁,以此往复直到全部结束,然后线程才开始工作,所以,我们还可以在最外面再加上一个判断,这样假如已经有了一个对象的话,就直接走,就不需要中间的申请锁之类的操作了。

因此,我们就可以得到一个线程安全的懒汉模式代码:

c++ 复制代码
template <typename T>
class Singleton{
public:
    static T* getInstance(){
        if(inst == nullptr)
        {
            mtx.lock();
            if(inst == nullptr)
            {
                inst = new T();
            }
            mtx.unlock();
        }
        return inst;
    }
private:
    static T* inst;
    static std::mutex mtx;
}

因为,我们知道线程池是只需要一个的,也就是只需要一个对象即可,所以,我们下面就把我们上面的线程池再进行修改一下,把它设计成为懒汉模式:

c++ 复制代码
threadpool.hpp:

#pragma once

#include "Thread.hpp"
#include "Mutex.hpp"
#include <queue>//盛放任务的载体
#include <vector>//用于盛放线程的载体
#include <mutex>

static const int gtdnum = 3;

template<class T>
class ThreadPool;

template<class T>
class ThreadData
{
public:
    ThreadData(ThreadPool<T>* this_, std::string name_):_this(this_), _name(name_)
    {

    }
public:
    ThreadPool<T>* _this;
    std::string _name;
};

template<class T>
class ThreadPool
{
private:
    static void* handler(void* args)//静态成员函数无法访问动态成员变量,所以需要另一个类来保存
    {
        ThreadData<T>* td = static_cast<ThreadData<T>*> (args);
        while(true)
        {
            td->_this->queue_lock();
            while(td->_this->task_empty()) 
            {
                td->_this->thread_wait();
            }
            T t = td->_this->pop();
            td->_this->queue_unlock();
            std::cout << td->_name << "正在执行" << t.toTaskString() << " 并完成了该任务: " << t() << std::endl;//TODO
            t();
        }
        delete td;
        return nullptr;
    }
private:
    void queue_lock()
    {
        pthread_mutex_lock(&_mutex);
    }
    void queue_unlock()
    {
        pthread_mutex_unlock(&_mutex);
    }
    void thread_wait()
    {
        pthread_cond_wait(&_cond, &_mutex);
    }
    bool task_empty()
    {
        return _task_queue.empty();
    }
    T pop()
    {
        T t = _task_queue.front();
        _task_queue.pop();
        return t;
    }
private:
    //构造函数需要设置为私有
    ThreadPool(int thread_num = gtdnum):_thread_num(thread_num)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_cond, nullptr);
        for(int i = 0; i < _thread_num; i++)
        {
            _thread_pool.push_back(new Thread());
        }
    }
public:
    void run()
    {
        for(auto &it :_thread_pool)
        {
            ThreadData<T>* td = new ThreadData<T>(this, it->getname());
            std::cout << it->getname() << " run......" << std::endl;
            it->start(handler, td);
        }
    }
    void push(const T& task)
    {
        LockGaurd lkg(&_mutex);
        pthread_cond_signal(&_cond);
        _task_queue.push(task);
    }
    
    ~ThreadPool()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_cond);
        for(auto &it :_thread_pool)
        {
            delete it;
        }
    }

    ThreadPool(const ThreadPool<T>&) = delete;//拷贝构造不允许
    void operator =(const ThreadPool<T> & td) = delete;//在单例模式中,赋值操作不允许
public:
    static ThreadPool<T>* getInstance()
    {
        if(tp == nullptr)
        {
            std::mutex mtx;
            mtx.lock();
            if(tp == nullptr)
            {
                tp = new ThreadPool<T>();
            }
            mtx.unlock();
        }
        return tp;
    }
private:
    std::vector<Thread*> _thread_pool;//线程池
    std::queue<T> _task_queue;//任务队列
    pthread_mutex_t _mutex;//互斥锁
    pthread_cond_t _cond;//条件变量
    int _thread_num;//线程最大数量
private:
    static ThreadPool<T>* tp;
};

template<class T>
ThreadPool<T>* ThreadPool<T>::tp = nullptr;

Thread.hpp:

#pragma once

#include <iostream>
#include <functional>
#include <string>
#include <cassert>
#include <cstdio>
#include <pthread.h>

#define NAME_NUM 1024

class Thread;//先声明这个类

template <class T>
class Context/*用于包装线程的信息*/
{
public:
    Context():this_(nullptr), args_(nullptr){ }
    Context(T* _this, void* args = nullptr):this_(_this), args_(args){ }
public:
    T* this_;
    void* args_;//参数
};

class Thread/*封装一个自己的线程库*/
{
public:
    using func_t = std::function<void*(void*)>;//重命名函数类型

public:
    Thread()
    {
        char namebuffer[NAME_NUM];
        snprintf(namebuffer, sizeof namebuffer, "thread-> %d", num_++);
        name_ = namebuffer;
    }

    void start(func_t func, void* args = nullptr)
    {
        func_ = func;
        args_ = args;
        Context<Thread>* ctx = new Context<Thread>();
        ctx->this_ = this;
        ctx->args_ = args_;
        int n = pthread_create(&tid_, nullptr, start_routine, ctx);
        assert(n == 0);
        (void)n;
    }
 private:

    static void* start_routine(void* args)//默认自带了一个this指针,所以该函数的类型为 void*(*)(void*,this):不是void*(*)(void*),不符合要求
    {
        Context<Thread>* ctx = static_cast<Context<Thread>*>(args);
        void * ret = ctx->this_->run(ctx->args_);
        delete ctx;
        return ret;
        //return func();//err:静态方法只能调用静态成员
    }

    void* run(void* args)//运行函数,用于包装func_
    {
        return func_(args);
    }

public:   
    int join()//线程等待
    {
        int n = pthread_join(tid_, nullptr);
        assert(n == 0);
        return n;
    }
    
    pthread_t gettid()
    {
        return tid_;
    }

    std::string getname()
    {
        return name_;
    }

    ~Thread()
    {
        //do nothing
    }

private:
    pthread_t tid_;//线程id
    std::string name_;//线程名字
    static int num_;//线程编号
    void* args_;//参数
    func_t func_;//子线程进行的操作
};

int Thread::num_ = 1;

Task.hpp:

#pragma once

#include <iostream>
#include <functional>
#include <cstdio>

class CalTask
{
private:
    using func_t = std::function<int(int, int, char)>;
public:
    CalTask(){}
    CalTask(int x, int y, func_t func, char opt):x_(x), y_(y), func_(func), opt_(opt) {}
    std::string operator ()()
    {
        int ret = func_(x_, y_, opt_);
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = %d", x_, opt_, y_, ret);
        return buffer;
    } 
    std::string toTaskString()
    {
        char buffer[1024];
        snprintf(buffer, sizeof buffer, "%d %c %d = ?", x_, opt_, y_);
        return buffer;
    }
private:
    func_t func_;
    int x_;
    int y_;
    char opt_;
};

std::string math = "+-*/%";

int mycal(int x, int y, char opt)
{
    int result;
    switch(opt)
    {
    case '+':
        result = x+y;
        break;
    case '-':
        result = x-y;
        break;
    case '*':
        result = x*y;
        break;
    case '/':
    {
        if(!y)
        {
            std::cerr << "div zero!" << std::endl;
            result = -1;
        }        
        else 
        result = x/y;
    }
        break;
    case '%':
    {
        if(!y)
        {
            std::cerr << "mod zero!" << std::endl;
            result = -1;
        }        
        else 
        result = x%y;
    }
        break;
    default:
        break;
    }
    return result;
}

Mutex.hpp:

#pragma once

#include <iostream>
#include <pthread.h>

//RAII性质的互斥量
class Mutex
{
public:
    Mutex(pthread_mutex_t* mutex = nullptr):mutex_(mutex) { }
    void lock()
    {
        if(mutex_) pthread_mutex_lock(mutex_);
    }
    void unlock()
    {
        if(mutex_) pthread_mutex_unlock(mutex_);
    }
    ~Mutex()
    {
        //nothing
    }
private:
    pthread_mutex_t* mutex_;
};

class LockGaurd
{
public:
    LockGaurd(pthread_mutex_t* mtx):mutex_(mtx)
    {
        mutex_.lock();
    }
    ~LockGaurd()
    {
        mutex_.unlock();
    }
private:
    Mutex mutex_;
};

main.cc:

#include "threadpool.hpp"
#include "Task.hpp"
#include <memory>
#include <ctime>
#include <unistd.h>


int main()
{
    srand((unsigned int)time(nullptr) ^ getpid() ^ 0x2342143);
    ThreadPool<CalTask>::getInstance()->run();
    while(true)
    {
        int x = rand()%10;
        int y = rand()%3;
        char op = math[rand()%math.size()];
        CalTask t(x, y, mycal, op);
        ThreadPool<CalTask>::getInstance()->push(t);
        sleep(1);   
    }
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/test_thread/test_CP/ThreadPool$ ./thread_test 
thread-> 1 run......
thread-> 2 run......
thread-> 3 run......
thread-> 1正在执行3 - 2 = ? 并完成了该任务: 3 - 2 = 1
thread-> 1正在执行8 + 0 = ? 并完成了该任务: 8 + 0 = 8
thread-> 2正在执行6 - 0 = ? 并完成了该任务: 6 - 0 = 6
thread-> 3正在执行5 % 2 = ? 并完成了该任务: 5 % 2 = 1
thread-> 1正在执行3 % 0 = ? 并完成了该任务: mod zero!
3 % 0 = -1
mod zero!
thread-> 2正在执行8 / 1 = ? 并完成了该任务: 8 / 1 = 8
thread-> 3正在执行6 * 1 = ? 并完成了该任务: 6 * 1 = 6

当然这里最好对 tp 进行用 volatile 进行修饰,这个关键字的作用是保证内存可见性,可以防止编译器过度优化,导致最后数据都只从 CPU 的寄存器中取,而不会去内存中查看对应的数据。

STL ,智能指针和线程安全

STL 的容器并不是线程安全的,因为 STL 容器设计的初衷就是为了将性能挖掘到极致,而线程安全的操作在上面我们就已经了解了这会很大程度上的影响性能,所以,STL 中的容器并不是线程安全的。因此,在多线程的环境下,通常需要程序员自己注意线程安全的问题,然后再对此进行操作。

对于 unique_ptr 而言没有线程安全的概念,因为其只在当前的代码块中起作用。

对于 shared_ptr 而言,其也不需要,因为标准库用原子操作(CAS)的方式保证了 shared_ptr 的计数器是高效原子的。

CAS 操作:当需要更新数据时,先检查当前内存值和之前的取到的内存值是否相等,假如相等,就把当前的内存值写入到主存中,不相等就就重试。本身的操作其实就是一个自旋的过程。

其他常见的锁

乐观锁:每次取数据的时候,都会乐观地认为数据不会被线程修改,所以不会上锁,但是假如需要进行数据更新的时候,那么就会开始检测数据是否发生改变。主要采用 2 种方式:版本号机制和 CAS 操作。

**悲观锁:**每次取数据的时候,都会悲观地认为数据会被线程修改,所以访问数据的时候都会进行上锁,当其他线程想对该数据进行访问的时候都会被阻塞挂起。我们常见的操作本质就是悲观锁,因为这种操作简单且能保证线程安全。

读者写者问题

理解读写者

在我们编写代码的过程中,可不可能存在对于修改临界资源的概率很小,但是读的概率很高呢?这种情况在多线程中是非常常见的。这里我先讲一个正常生活中的故事,在我们小学,初高中的时候,我们通常可以看见教室后面的黑板报吧,这个黑板报会存在让我们整个教室只能排队一个人一个人地上去观摩吗,而且一个人观摩的时候别人还得把眼睛闭上?这应该是不存在的吧,而且黑板报修改的话,其实也是只有班上少数学生需要去画,这也就对应了上面修改临界资源的概率小,但是读的概率很高。我们知道读数据的过程中,查找数据更费时,假如此时我们还为读数据加上锁的话,这会很大程度地影响读数据的效率,那么有没有什么解决方案呢?有,那就是下面的主角读写锁。

这个读者写者其实也是遵从 321 原则。

3 指的是 3 个关系:

写者之间:互斥关系。

读者写者之间:互斥关系、同步关系。

读者之间:没有任何关系。

此时就会有人有疑问了:那为什么上面的生产消费模型中消费者之间会有互斥关系,但是这里却没有呢?

这里存在一个本质区别:读者不会对缓冲区修改数据,而消费者会对缓冲区修改数据,所以结论也就出来了,因为读者不需要修改数据,所以就它们之间就不会存在所谓的关系,而消费者之间会有对临界资源的访问的冲突,所以会存在互斥关系。

2 指的是 2 个角色:写者(画黑板报的学生),读者(看黑板报的学生)。

1 指的是 1 个中间缓冲区(黑板)。

下面就要先介绍读写锁的行为了。

注意:写数据的时候是只允许一个线程进行写的,读数据的时候,允许多个线程进行读数据,但是不允许写数据。

读写锁基本操作

scss 复制代码
pthread_rwlockattr_setkind_np():

函数本身:

c 复制代码
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);

这里的第一个参数是读写锁的一个环境参数,正常设置 nullptr 即可。

第二个参数是用于设置读写锁的一个优先级用的,一共包括 3 个选择。

PTHREAD_RWLOCK_PREFER_READER_NP:(默认设置) 读者优先,可能会导致写者饥饿情况

PTHREAD_RWLOCK_PREFER_WRITER_NP:写者优先,目前有 BUG,导致表现行为和 PTHREAD_RWLOCK_PREFER_READER_NP 一致

PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP:写者优先,但写者不能递归加锁

功能

设置读写锁的优先级。

返回值:

成功返回 0,失败则返回一个非 0 的错误码。

scss 复制代码
pthread_rwlock_init():

函数本身:

c 复制代码
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
           const pthread_rwlockattr_t *restrict attr);

第一个参数是要初始化的读写锁。

第二个参数是读写锁的环境参数,正常设置 nullptr。

功能:

跟之前的接口相比,是对读写锁进行初始化的功能。

返回值:

成功返回 0,失败返回错误码。

既然有了初始化,那正常来说,销毁肯定是也要有的。

scss 复制代码
pthread_rwlock_destroy():

函数本身:

c 复制代码
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

传入的参数就是我们要销毁的读写锁。

下面就是关于锁的操作了。

scss 复制代码
pthread_rwlock_rdlock():

函数本身:

c 复制代码
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

传入的参数就是我们要给哪个读写锁的读权限上锁。

功能:

将传入的读写锁中的读上锁。

返回值:

成功返回 0,失败返回错误码。

scss 复制代码
pthread_rwlock_wrlock():

函数本身:

c 复制代码
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

传入的参数就是我们要给哪个读写锁的写权限上锁。

有人肯定会有疑问:为什么读写锁的读写权限都只用一个参数就可以表示了?

当然有的人估计已经猜到了结局:其实 pthread_rwlock_t 这个数据类型本质上是一个结构体。

经过查询还真是:

c 复制代码
typedef union
{
  struct __pthread_rwlock_arch_t __data;
  char __size[__SIZEOF_PTHREAD_RWLOCK_T];
  long int __align;
} pthread_rwlock_t;

跟之前的条件变量的 pthread_cond_t 和 信号量的 sem_t 一样都是联合体,至于原因跟 pthread_cond_t 一样。

将读锁和写锁都封装到了一起,到时候对读进行上锁或对写进行上锁本质是向其内部的对应的锁进行上锁。

返回值:

成功返回 0,失败返回错误码。

scss 复制代码
pthread_rwlock_unlock():

函数本身:

c 复制代码
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

传入的参数就是我们要解锁的读写锁。

功能:

对读写锁里面的读锁和写锁都进行解锁。

返回值:

成功返回 0,失败返回错误码。

相关推荐
翱翔-蓝天1 分钟前
Spring Boot使用线程池创建多线程
java·spring boot·后端
旧厂街小江1 分钟前
LeetCode第76题:最小覆盖子串
后端·算法·程序员
uhakadotcom3 分钟前
ClickHouse与PostgreSQL:数据库的选择与应用场景
后端·面试·github
闯闯的日常分享9 分钟前
浅析HTTP与HTTPS的区别
后端
用户2587141932639 分钟前
深入浅出--Linux基础命令知识(总结,配图文解释)
linux·ubuntu
四七伵10 分钟前
MySQL主键生成的4种方式:优缺点及性能对比!
后端·mysql
追逐时光者14 分钟前
C#/.NET/.NET Core技术前沿周刊 | 第 30 期(2025年3.10-3.16)
后端·.net
曼岛3517 分钟前
[密码学实战]Java实现TLS 1.2单向认证
后端
牛肉汤17 分钟前
有了MESI缓存一致性协议为什么还需要volatile?
后端
Java技术小馆23 分钟前
Java中的Fork/Join框架
java·后端·面试