并发编程(二)—— 多线程

如果我们把进程比作一个工人的话,那线程就是工人的四肢。怎么理解这个比喻呢?

  • 工人拥有四肢,进程拥有线程
  • 工人干活要依靠手脚来做事,进程干活也是依靠线程来做事
  • 工人和工人之间只能通过工头调度或者相同的操作手册(大脑中的知识)来协作,进程和进程之间只能通过操作系统内核调度或者共享内存来协作
  • 工人的四肢用工人自己的大脑来协调,进程的线程用进程自己的虚拟内存空间来协作
  • 一个工人的四肢的协调效率比多个工人之间的协调效率高,一个进程的线程之间的协作效率比多个进程之间的协作效率要高

一、为什么要使用多线程并发

在已有多进程并发的机制下,多线程并发的优势从上面的比喻中就能得知:

  • 更方便的通信机制:同一进程的多线程共享进程的虚拟内存空间和文件描述符表
  • 更快的切换速度:在线程之间切换的速度比进程之间要快,因为前者不需要创建和切换虚拟内存空间

二、线程的内存模型

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_structthread_info 中的一个指针来寻址。由其名称为 thread_info 而不是 task_info 可知其实每次切换进程上下文时是以进程的某个线程为目标的。
除了 thread_info 之外,RSP 寄存器和 RBP 寄存器保存着的栈帧指针,以及 PC 寄存器保存的指令地址,都属于线程上下文。也就是说,线程上下文是进程上下文的一个特例:

  • 特例的 thread_info
  • 特例的 RSPRBPPC 寄存器
  • 当然还有和线程运行状态相关的通用寄存器和标志寄存器

由此可以看出,在进程的线程之间切换时,不需要切换 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
// helper.c
#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);
}
}

// create_thread.c
#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
// cancel_thread.c
#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
// exit_thread.c
#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
// join_thread.c
#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
// main_thread_exit.c
#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
// detach_thread.c
#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 两种选择。