在我们的进程虚拟地址的代码区,对于代码中的每个函数都有对应的地址,每个函数中的每行代码都有对应的代码,并且每个函数中的每行代码的地址都是连续的。既然代码是连续的,也就意味着我们可以将我们代码分块,分成不同的代码块。划分出不同的代码块之后,我们能否按照其在页表中的连续映射将其分配给不同的进程并行运行呢?
如果可以的话,我们就完成了将一份代码并行运行,提高了运行的效率。
这就是多线程,多线程就可以让我们的代码并行运行起来,本篇从线程的基本概念开始介绍,然后比较了多线程与多进程之间的区别。接着介绍了在 Linux 系统下的线程控制,介绍了在 Linux 系统下使用多线程的函数接口。然后讲解了线程的互斥,需要使用锁互斥,同时揭示了互斥的缺点,然后引入了线程同步,需要使用条件变量来解决。在介绍完条件变量之后,我们又介绍了在多线程中常用的生产消费模型,实现了两种经典的生产消费模型:阻塞队列(使用条件变量和互斥锁完成)和环形队列(使用信号量和互斥锁完成)。同时还介绍了线程安全和可重入以及死锁的概念。
最后结合以上的知识,写了一个单例(懒汉)模式的线程池。
目录
线程的概念
线程:在进程的内部运行,是 CPU 调度的基本单位。在 Linux 系统下的线程,是在进程的地址空间中运行。如下图:
在如上的进程中,我们有着多个进程的 PCB(task_struct),对于每个 PCB 都在代码区中分配了不同的代码的起始地址,每个 PCB 都分别运行属于分配自己的代码块,相当于每个线程拿了一部分页表,每个线程使用页表的一部分,最终达到实现并行运行。在没谈线程之前,每个进程的 PCB 都只有一个,现在谈到线程时,对于进程的定义我们可以更深一步:
进程的定义:
内核数据结构 + 进程的代码和数据;
承担分配系统资源的基本实体。(对于资源的分配包括CPU资源的分配,物理内存的分配等等)
以上两种说法都是进程的定义。 在一个进程中会存在多个进程 PCB,对于每个进程 PCB,系统都会给其在代码区分配不同的资源,同时也会表现在一个进程的不同 PCB 用着不同的 CPU 资源。
1. Linux 和 windows 下的线程
操作系统对于线程的设计,需要包括到在线程中的新建、暂停、销毁、调度等等的功能,也就意味着操作系统需要将我们的线程给管理起来,就需要设计出一个独立的数据结构,其中包括线程的 id、优先级、状态、连接属性等等(进程为 PCB,线程会 TCB),然而在我们的 windows 系统下就设计出来这样的结构(当 windows 下运行多线程的时候 CPU 在连接进程的同时,还需要连接连接在进程后面的线程,设计这样的结构的同时还需要单独的设计出对应的管理调度算法)。
然而在 Linux 系统下,由于在线程中的属性在进程中也存在,所以在 Linux 系统下就没有单独的设计出 TCB 的数据结构,而是沿用了 PCB 的数据结构,这样的设计还节省了调度算法的设计,TCB 与 PCB 使用同一套调度算法,减轻了线程的调度复杂度。所以在 Linux 系统中对于线程的说法,也可以叫做轻量级进程。
所以联系到线程的概念,线程是 CPU 调度的基本单位,在 Linux 中 CPU 调度线程的时候,根本就不会在意是线程还是进程,只会在意是当前调度的一个基本单位。
2. 线程的创建与使用
接下来介绍关于在 linux 系统下创建线程的函数,如下:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg); pthread_t *thread 表示需要传入一个pthread_t 的参数,一个输出型参数 const pthread_attr_t *attr 线程的属性,通常传入 nullptr void *(*start_routine) (void *) 创建出的线程需要执行的方法 void *arg 传入方法的参数
当使用如上系统调用之后,主进程会继续向下运行,新线程会继续向下运行。另外在我们的 linux 下使用该系统调用需要连接动态库 pthread,使用如下代码测试创建出的多线程,如下:
#include <iostream> #include <pthread.h> #include <unistd.h> void* StartNewThread(void* args) { const char* name = static_cast<const char*>(args); while (true) { sleep(1); std::cout << name << " running..." << ", pid:" << getpid() << std::endl; } } int main() { pthread_t tid; pthread_create(&tid, nullptr, StartNewThread, (void*)"new thread"); while (true) { std::cout << "main thread running..." << ", pid:" << getpid() << std::endl; sleep(1); } return 0; }
测试结果如下:
当我们运行的时候,主线程和新线程的 pid 一模一样,然后我们使用指令 ps -aL 查询当前的执行流,我们可以看到一个关键字 LWP(light weight process 轻量级进程),其中 pid 和 lwp 相等的为主线程。所以在我们的操作系统中,操作系统调度的时候,看的执行流的 LWP,看的不是执行流的 pid,因为多线程的 pid 一样。
对于传入的方法 void *(*start_routine) (void *) 其中传入的参数为 void *arg ,这个参数的类型为一个 void* 的一个指针,说明我们可以对这个传入任意类型的指针,传入之后在方法中又强转回来,如下:
pthread_t tid; void func() { // .... } pthread_create(&tid, nullptr, StartNewThread, (void*)&func); pthread_create(&tid, nullptr, StartNewThread, (void*)"new thread"); int a = 0; pthread_create(&tid, nullptr, StartNewThread, (void*)&a); std::vector<std::string> vs; pthread_create(&tid, nullptr, StartNewThread, (void*)&vs);
所以我们可以根据这个 void* 类型的指针传入任意的方法或者类型给我们的线程。
3. 线程之间共享的数据和独有的数据
多线程拥有同一个地址空间和页表,也就意味着多线程的大部分数据都是共享的,当让也存在部分不是共享的数据。
共享的数据如下:
1. 文本段和数据段都是共享的,因为共享同一个地址空间,如定义了一个函数,在各个线程中都可以进行调用。
2. 文件描述符表。在每个线程中使用打印函数都可以打印到屏幕上,就是因为共享了文件描述符表。
3. 各种信号的处理方式(SIG_IGN、SIG_DFL、自定义方法)
4. 当前工作目录
5. 用户 id 和 组 id
不共享的数据:
1. 线程 ID 也即是 LWP
2. 一组寄存器。每个线程的一组寄存器对应着该线程执行的上下文数据,切换前执行到哪里,有着哪些数据。
3. 栈。线程在执行时产生的临时变量都会保存到自己的栈结构中。
4. erron 错误码
5. 信号屏蔽字
6. 调度的优先级。
多线程和多进程的区别
多线程和多进程都可以实现并行执行任务,那么多线程和多进程之间有哪些区别呢?
1. 进程创建成本非常高,一个进程的创建需要构建地址空间、页表,加载代码和数据以及建立映射关系,还有建立信号系统等等;然而创建线程只需要在原来的进程上创建出一个 pcb,以及将对应的代码和数据关联起来就可以,所以线程的创建相对于进程的创建的成本非常小。 —— 启动
2. 线程的调度成本低,对于线程的调度只需要切换相关的 pcb,对于进程而言则不是这样的,进程的切换需要先保存寄存器中的数据,然后切换其他线程地址空间和页表。(其实地址空间和页表的也换也仅仅只是几个寄存器进行切换,花的时间不是很多。真正耗时的为:在 CPU 内有着缓存 Cache,会将访问数据的附近数据也给加载进来,当我们切换线程的时候,很可能 Cache 中加载的数据还可以用得到,但是假设我们切换了进程, CPU 内 Cache 数据就作废了,就需要重新加载另一个进程的数据,这里才是真正耗时的)—— 运行
3. 删除一个线程的成本低,删除一个线程只需要删除对应的 pcb,而删除一个进程需要将进程的代码数据,地址空间,页表等等全都释放。 —— 删除
4. 多线程的安全性低于多进程,当多线程的代码发送错误的时候,操作系统会向进程 pid 发送信号,但是所有线程的 pid 都是一样的,也就意味着,一个线程出错,所有的线程都会被进程发送来的信号给杀掉,所有其他的任务都崩掉,正常的任务也没了。然而在多进程执行任务的时候,崩掉也只是崩掉一个,崩掉一个任务。
1. 线程的优点
以上对比的是线程和线程之间的区别,现在总结一下线程的优点:
1. 创建线程的代价小、线程占用的资源相对较少;
2. 能够充分利用多处理器的可并行数量(多进程也可以);
3. 等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务;
4. 计算密集型应用,为了能在多处理器上运行,将计算分解到多个线程中实现(对于多线程的创建,最好根据 CPU 的核数来创建,线程并不是创建得越多越好,若 CPU 的核的数量少于线程数,有些线程还是需要串行运行,并且还增加切换线程的时间)
5. I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。(对于 I/O 密集型则可以允许创建多线程,比如我们需要下载10G的一个应用,我们则可以创建出10个线程,每个线程下载一个G,线程等待下载的时间可以重叠,则可以加快我们的下载速度)
2. 线程的缺点
线程既有优点,也会存在缺点:
1. 线程的健壮性低。多线程中的线程只要一个出现问题,那么所有的线程都可能会受到影响。
2. 缺少访问控制。对于一个进程中的大部分数据,线程都可以看见,也就意味着线程可以更改其他线程需要的数据,这样的访问机制不是很安全。
3. 性能缺失。当创建出的线程数大于 CPU 核数,就会导致有些线程处于串行运行状态,反而增加了运行的成本。
线程的控制
在 Linux 系统中没有真正意义上的线程(没有 tcb),只有轻量级进程,所以对于线程提供的线程控制,和线程的定制的标准的线程库有区别,Linux 向上提供的是轻量级进程的接口。所以为了将轻量级进程的接口和标准的线程库相符,Linux 向上提供了一个 pthread 动态库,对轻量级进程进行封装,所以这也是为什么我们在调用这些接口的时候,需要链接 pthread 库。
1. 线程的等待
当我们的线程任务执行结束之后,我们还是需要将对应的线程回收。所以这个时候就需要主线程使用系统调用 pthread_join 回收我们的线程,如下:
int pthread_join(pthread_t thread, void **retval); thread 表示线程id,我们需要等待的线程 retval 线程执行函数的返回值,不关心可以设置为nullptr 返回值为0表示我们的等待成功,若为其他值则表示等待失败
当主进程使用该系统调用等待我们的线程的时候,线程只要没有退出,主线程就会一直阻塞的等待我们的线程。(对于线程的等待,其实不管是否等待,等到主线程结束的时候,其余的线程同时会被释放,但是这样的作法是非常不推荐的。若不等待线程,可能造成线程还未执行完他的任务的时候就已经退出的情况,因为主线程可能提前退出;还有可能会照成类似僵尸进程的情况,线程执行任务结束之后,主线程未将其回收且主进程未退出,操作系统会将线程的 PCB 一直维护起来)
如下的线程等待代码:
void* StartNewThread(void* args) { const char* name = static_cast<const char*>(args); int cnt = 5; while (cnt--) { std::cout << name << " running..." << ", pid:" << getpid() << std::endl; sleep(1); } return (void*)name; } int main() { pthread_t tid; pthread_create(&tid, nullptr, StartNewThread, (void*)"new thread"); void* ret = nullptr; int n = pthread_join(tid, &ret); if (n == 0) std::cout << "wait thread success..." << std::endl; std::cout << static_cast<const char*>(ret) << std::endl; return 0; }
如上的代码,当我们想要获取对应线程执行函数的返回值,就可以使用一个 void* 类型的变量取地址去接收它(也可以使用其他变量指针强转成 void** 类型),若我们不关心则直接传入 nullptr。
2. 线程终止
线程的终止可以是线程执行函数直接 return,或者是主线程直接退出了。这些退出方式都是一些自然的退出方式,若想要让进程直接了当的退出,该如何退出呢?
我们可以使用函数 exit,当然这个函数需要谨慎使用,因为只要在线程执行函数中使用该函数就会使得整个进程退出,所以一般我们不适用该函数。
可以使用专门的接口 pthread_exit,如下:
void pthread_exit(void *retval); 函数中的参数就是线程返回函数的返回值,可以设置为nullptr
我们只需要在线程执行函数中调用该接口,就可以使得线程直接退出。
还存在一种线程退出方式,使用系统调用接口 pthread_cancel,在主线程中使用该函数之后可以直接将对应的线程给取消,如下:
int pthread_cancel(pthread_t thread); 传入线程的id 当使用该函数将线程取消之后,线程的返回值为:0 PTHREAD_CANCELED -> #define PTHREAD_CANCELED ((void *) -1)
在主线程中使用该函数可以直接终止线程。
3. 线程分离
我们将线程创建出来之后,可不可以不等待我们的线程,让线程结束执行完线程函数之后直接退出的呢?
我们只需要将我们的线程分离,就可以达到这样的目的,只需要使用我们的系统调用函数 pthread_detach,如下:
int pthread_detach(pthread_t thread); 参数传入线程id pthread_t pthread_self(void); 哪个线程调用,就返回哪个线程的id
当我们使用函数将线程分离之后,我们就不用在等待我们的线程了。若我们还是使用 pthread_join 等待我们的函数,就会等待失败,其返回值就不会为 0。但是我们在使用线程分离函数的时候需要注意:要保证主线程在其他线程结束之后在退出,否则会导致有的线程还未执行结束,就被迫退出。但是我们还是需要注意即使线程分离,我们的线程还是在进程内部,当某个线程出现错误,还是会导致进程接收信号而终止。
4. 线程的 tid
不管是使用系统调用创建我们的线程,还是 join 等待我们的线程,我们都需要将我们的线程 id 传入系统调用函数中,那么线程 id 到底是什么呢?如下图: