如果我们把进程比作一个工人的话,那线程就是工人的四肢。怎么理解这个比喻呢?
工人拥有四肢,进程拥有线程
工人干活要依靠手脚来做事,进程干活也是依靠线程来做事
工人和工人之间只能通过工头调度或者相同的操作手册(大脑中的知识)来协作,进程和进程之间只能通过操作系统内核调度或者共享内存来协作
工人的四肢用工人自己的大脑来协调,进程的线程用进程自己的虚拟内存空间来协作
一个工人的四肢的协调效率比多个工人之间的协调效率高,一个进程的线程之间的协作效率比多个进程之间的协作效率要高
一、为什么要使用多线程并发
在已有多进程并发的机制下,多线程并发的优势从上面的比喻中就能得知:
更方便的通信机制:同一进程的多线程共享进程的虚拟内存空间和文件描述符表
更快的切换速度:在线程之间切换的速度比进程之间要快,因为前者不需要创建和切换虚拟内存空间
二、线程的内存模型
2.1 线程栈
进程的多个线程之间共享虚拟内存空间,但是每个线程有自己独立的线程栈。
也就是说,一个进程可能有多个栈内存段,它们分别属于不同的线程。我们之前说过,栈内存是随着函数栈而自动伸缩的,那这些栈内存段如何保证不会互相覆盖呢?事实是,栈内存空间会自动伸缩,但是栈内存段的大小是固定的一个操作系统常量。由于栈内存段大小是一个常量,所以很容易实现相连且不覆盖的布局。当栈空间超过栈内存段大小时,就会出现 stack overflow 错误。
线程和线程之间的栈内存段是独立的,但是彼此却不设防。也就是说只要地址已经分配了 vma,那就可以通过地址值进行读写。
2.2 线程上下文
先来回顾一下进程的上下文:
CPU 寄存器(通用寄存器、浮点寄存器、标志寄存器、PC 寄存器、CR3 寄存器、栈指针寄存器、帧指针寄存器)
由 CR3 寄存器指出的页表,由页表指出虚拟内存空间
由虚拟内存空间顶部的 thread_info::task*
指出的 vma
由虚拟内存空间顶部的 thread_info::task*
指出的文件描述符表
由虚拟内存空间顶部的 thread_info::task*
指出的其它信息(state、signal、pid、gpid等)
可以看出来对进程至关重要的数据结构 task_struct
由 thread_info
中的一个指针来寻址。由其名称为 thread_info
而不是 task_info
可知其实每次切换进程上下文时是以进程的某个线程为目标的。
除了 thread_info
之外,RSP
寄存器和 RBP
寄存器保存着的栈帧指针,以及 PC 寄存器保存的指令地址,都属于线程上下文。也就是说,线程上下文是进程上下文的一个特例:
特例的 thread_info
特例的 RSP
、RBP
和 PC
寄存器
当然还有和线程运行状态相关的通用寄存器和标志寄存器
由此可以看出,在进程的线程之间切换时,不需要切换 CR3 寄存器 ,但是还是要切换其它的 CPU 寄存器的。
三、多线程协作 API
和多进程一样,线程也有一系列的从创建到回收的 API。
4.1 创建线程
怎么创建一个线程呢?
首先当然是 fork
,因为 fork
创建了一个进程,而每个进程都有一个线程。
但是 pthread_create
才是更常用的创建线程的方法,其原型为:
1 2 3 4 void pthread_create (pthread_t * new_thread, const pthread_attr_t * thread_attr, (void *)(thread_function*)(void *args), void * args) );
一般来说,每个进程的第一个线程被称为“主线程”,虽然说带一个“主”字,但其实所有的线程都是平等的关系。任意线程可以中止任意线程。
下面是一个创建线程的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 #include <cstdio> #include <thread> #include <zconf.h> const int thread_num = 5 ;#define THREAD_NUM thread_num pthread_t threads[THREAD_NUM];#define PUT_IDX_TO_HEAP(i) (size_t *)malloc(sizeof(size_t)); *idx = (i) #define GET_DIX_FR_HEAP(ptr) *(size_t*)ptr void Sleep (size_t scs) { size_t left_scs = scs; while (left_scs) { left_scs = sleep(left_scs); } } #include "helper.h" void *thread_func (void *idx_ptr) { size_t idx = GET_DIX_FR_HEAP(idx_ptr); printf ("thread of idx %02ld is running.\n" , idx); return NULL ; } int main () { for (size_t i = 1 ; i < THREAD_NUM; i++) { size_t * idx = PUT_IDX_TO_HEAP(i); pthread_create(&threads[i], NULL , thread_func, idx); } Sleep(1 ); printf ("Main Thread Done" ); }
4.2 中止线程
任意线程可以中止其它线程,API 为 pthread_cancel
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 #include "helper.h" void *echo_thread_id (void *idx_ptr) { size_t idx = GET_DIX_FR_HEAP(idx_ptr); size_t sibling = (idx + 1 ) % 5 ; while (true ) { printf ("Thread of idx %02ld is running.\n" , idx); pthread_cancel(threads[sibling]); if (sibling == 0 ) printf ("Thread of idx %02d canceled Main Thread.\n" , idx, sibling); else printf ("Thread of idx %02d canceled sibling %02d.\n\n" , idx, sibling); Sleep(1 ); } return NULL ; } int main () { pthread_t tid = pthread_self(); threads[0 ] = tid; for (size_t i = 1 ; i < THREAD_NUM; i++) { size_t *idx = PUT_IDX_TO_HEAP(i); pthread_create(&threads[i], NULL , echo_thread_id, idx); } for (size_t i = 1 ; i < THREAD_NUM; i++) { printf ("Main Thread is running.\n" ); pthread_cancel(threads[i]); printf ("Main thread canceled thread of idx %02ld\n\n" , i); Sleep(1 ); } }
运行这个程序多次,发现每次的输出不一样,而且理论上每个程序都被中止了,但是有时候程序还是会在某几个线程上无线循环。原因嘛,就是并发情况下的竞速冲突,这个以后再专门来分析。
4.3 终止线程
在正常情况下,如果线程的主函数退出,那线程就终止了。此外,当任意线程导致进程退出时,比如异常或者调用 exit
,自然就终止了所有线程。
对于 main
函数来说,不管最后面有没有 return
语句,都会被转换成 exit
调用。而 exit
调用会导致其它线程全部退出。所以很多人误以为主线程退出会导致其它线程退出,其实根本原因是 main
函数结束前调用了 exit
。有什么办法避免这种现象嘛?当然是有的,最简单的方法是在主线程中使用某种阻塞手段,还有一种取巧的办法:在其它线程中中止主线程。
如果线程只想退出自己而不影响其它的线程,那就不要用 exit
,而是要用 pthread_exit
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #include "helper.h" void *echo_thread_id (void * idx_ptr) { while (true ) { size_t idx = GET_DIX_FR_HEAP(idx_ptr); printf ("Thread of idx %02ld is running.\n" , idx); pthread_exit(NULL ); } } int main () { pthread_t tid = pthread_self(); threads[0 ] = tid; for (size_t i = 1 ; i < THREAD_NUM; i++) { size_t * idx = PUT_IDX_TO_HEAP(i); pthread_create(&threads[i], NULL , echo_thread_id, idx); } Sleep(1 ); }
4.4 等待线程
在刚才的例子中,我们在主线程中使用 sleep
来阻塞自己以避免 main
函数过早调用 exit
来杀死所有线程。其实还有一个专门等待线程的 API thread_join
。它会挂起当前线程,直到指定的线程结束后才开始运行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #include <cstring> #include "helper.h" #define PTHREAD_JOIN(tid) int r = pthread_join((tid), NULL); \ if (r != 0) printf("failed to join thread: %02d %s\n" , r, strerror(errno)); void *thread_func (void *idx_ptr) { size_t idx = GET_DIX_FR_HEAP(idx_ptr); if (idx > 1 ) { pthread_join(threads[idx - 1 ], NULL ); printf ("thread of idx %02ld waited %02ld.\n" , idx, idx - 1 ); } printf ("thread of idx %02ld is running.\n" , idx); return NULL ; } int main () { for (size_t i = 1 ; i < THREAD_NUM; i++) { size_t *idx = PUT_IDX_TO_HEAP(i); pthread_create(&threads[i], NULL , thread_func, idx); } PTHREAD_JOIN(threads[4 ]); printf ("Thread Main waited %02ld.\n" , 4l ); printf ("Thread Main Done\n" ); }
除了 thread_join
,其实在主线程上调用 thread_exit
会等待所有其它线程退出,这算是主线程的唯一特权:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #include "helper.h" void *thread_func (void *idx_ptr) { size_t idx = GET_DIX_FR_HEAP(idx_ptr); printf ("thread of idx %02ld is running.\n" , idx); return NULL ; } int main () { for (size_t i = 1 ; i < THREAD_NUM; i++) { size_t * idx = PUT_IDX_TO_HEAP(i); pthread_create(&threads[i], NULL , thread_func, idx); } printf ("Main Thread Done\n" ); pthread_exit(NULL ); }
4.5 回收线程资源
线程占有哪些资源呢?
CPU 资源不用去操心,线程结束自然就释放了 CPU 资源。
内存资源呢?线程的大部分内存资源属于进程,但是在内核中还有其 thread_info
,在用户内存空间中还有栈内存段,这些在线程结束后是不会释放的,甚至在进程结束后也不会马上释放,要等到父进程中的 waitpid
系统调用中才会释放。
如此一来系统中岂不是充满了“僵尸线程”?操作系统设计者不会这么蠢,但也没那么聪明。
怎么说?首先,操作系统确实提供了一个回收线程资源的 API,作用类似 waitpid
,这个 API 就是刚才提到的 pthread_join
。但是别高兴得太早,pthread_join
不能和 waitpid
等待所有的子进程一样等待所有的线程(也没有“子”线程的概念嘛),而且它只能是阻塞的。也就是说,必须显示地对每个 thread 进行 join 操作,而且 join 后会阻塞。如果是简单的项目,创建线程地逻辑很单一还好说,但是遇到深层次调用中地创建线程…负责程序中出现 join 的死锁也是有可能的(当然在调用 join 时操作系统会检查到这个错误并制止)。
这时还有另外一个办法:pthread_detach
。通过 pthread_detach
调用,可以把指定的线程变成分离的。分离的线程不能被其它线程 join,但是当分离的线程结束后,操作系统会自动回收它的资源。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 #include "helper.h" #include <cstring> #define THREAD_DETACH(tid) int r = pthread_detach((tid)); \ if (r != 0) printf("failed to detach thread: %d, %s.\n" , r, strerror(errno)) void *echo_thread_id (void *idx_ptr) { size_t idx = GET_DIX_FR_HEAP(idx_ptr); while (true ) { printf ("Thread of idx %02ld is running.\n" , idx); Sleep(1 ); } return NULL ; } int main () { pthread_t tid = pthread_self(); threads[0 ] = tid; for (size_t i = 1 ; i < THREAD_NUM; i++) { size_t *idx = PUT_IDX_TO_HEAP(i); pthread_create(&threads[i], NULL , echo_thread_id, idx); THREAD_DETACH(threads[i]); printf ("Main Thread make thread of idx %02ld detached.\n" , i); } while (true ) { printf ("Main Thread is running.\n\n" ); Sleep(1 ); } }
除了 pthread_detach
之外,还可以在创建线程的时候就利用第二个参数指定新线程为分离的,这个 API 就不再细述了。
有的人(大名鼎鼎的 CS:APP)说线程 detach 之后不能被其它线程用 pthread_cancel
中止,但是我验证后发现情况并不是如此,分离的线程仍然可以被其它线程中止。查询 Linux 文档也说 pthread_detach
不会影响其它属性:
The detached attribute merely determines the behavior of the system when the thread terminates
Anyway,对于每一个线程,要么 pthread_join
要么 pthread_detach
。
四、总结
任何进程都以线程的模式来运行,或者说,进程是操作系统进行内存资源分配的基本单元,线程是操作系统进行 CPU 资源分配的基本单元。
多线程并发比多进程并发的优势在于两处:一是创建线程比创建进程轻量,二是线程比进程更容易共享数据。
使用多线程仍然要注意及时回收线程的资源,有 john 和 detach 两种选择。