1. 线程概述
与进程(process)类似,线程(thread)是允许应用程序并发执行多个任务的一种机制。
如上图所示,一个进程内可以包含多个线程。同一程序中的所有线程均会独立执行相同程序(即有共同的代码段),且共享同一份全局内存区域,其中包括初始化数据段(initialized data)、未初始化数据段(uninitialized data),以及堆内存段(heap segment)。(传统意义上的UNIX 进程只是多线程程序的一个特例,该进程只包含一个线程。)。
除此之外,线程间还共享一些其他的属性:
- 进程ID和父进程ID
- 打开的文件描述符
- 信号处理器程序和信号的处理动作
- 当前的工作目录
同样线程间还有些独享的属性:
- 线程ID
- 信号掩码
- errno
- 寄存器集合,包括程序计数器和栈指针
- 优先级
同一进程中的多个线程可以并发执行。在多处理器环境下,多个线程可以同时并行。如果一线程因等待I/O 操作而遭阻塞,那么其他线程依然可以继续运行。
传统UNIX通过创建多个进程来实现并行任务。以网络服务器的设计为例,服务器进程(父进程)在接受客户端的连接后,会调用fork()
来创建一个单独的子进程,以处理与客户端的通信。采用这种设计,服务器就能同时为多个客户端提供服务。虽然这种方法在很多情境下都屡试不爽,但对于某些应用来说也确实存在如下一些限制。
- 进程间的信息难以共享。由于除去只读代码段外,父子进程并未共享内存,因此必须采用一些进程间通信(inter-process communication,简称IPC)方式,在进程间进行信息交换。
- 调用
fork()
来创建进程的代价相对较高。即便利用写时复制(copy-on-write)技术,仍然需要复制诸如内存页表(page table)和文件描述符表(filedescriptor table)之类的多种进程属性,这意味着fork()
调用在时间上的开销依然不菲。
线程解决了上述两个问题。
- 线程之间能够方便、快速地共享信息。只需将数据复制到共享(全局或堆)变量中即可。不过,要避免出现多个线程试图同时修改同一份信息的情况,这就需要同步技术。
- 创建线程比创建进程通常要快10 倍甚至更多。(在 Linux 中,是通过系统调用 clone()来实现线程的)线程的创建之所以较快,是因为调用 fork()创建子进程时所需复制的诸多属性,在线程间本来就是共享的。特别是,既无需采用写时复制来复制内存页,也无需复制页表。
2. Pthreads API
20世纪80年代末、90年代初,线程有许多不同的实现,所以也一直没有一个统一的线程接口。1995年著名的POSIX标准终于对线程接口进行了标准化,也即Pthreads API。下面简要介绍Pthreads API中用到的一些概念。
线程数据类型
Pthreads API 定义了一干数据类型,下表列出了其中的一部分。后续内容会对这些数据类型中的绝大部分加以描述。
数据类型 | 描述 |
---|---|
pthread_t | 线程ID |
pthread_mutex_t | 互斥对象(Mutex) |
pthread_mutexattr_t | 互斥属性对象 |
pthread_cond_t | 条件变量(condition variable) |
pthread_condattr_t | 条件变量的属性对象 |
pthread_key_t | 线程特有数据的键(Key) |
pthread_once_t | 一次性初始化控制上下文(control context) |
pthread_attr_t | 线程的属性对象 |
标准并未规定如何实现这些数据类型,可移植的程序应将其视为不透明数据。亦即,程序应避免对此类数据类型变量的结构或内容产生任何依赖。尤其是,不能使用C语言的比较操作符(==)去比较这些类型的变量。
errno
在传统UNIX API 中,errno
是一全局整型变量。然而,这无法满足多线程程序的需要。如果线程调用的函数通过全局errno
返回错误时,会与其他发起函数调用并检查errno
的线程混淆在一起。因此,在多线程程序中,每个线程都有属于自己的errno。在Linux 中,线程特有errno
的实现方式与大多数UNIX 实现相类似:
将errno
定义为一个宏,可展开为函数调用,该函数返回每个线程各自的errno
。
Pthreads API 返回值
从系统调用和库函数中返回状态,传统的做法是:返回0表示成功,返回-1表示失败,并设置errno
以标识错误原因。Pthreads API 则反其道而行之。所有Pthreads函数均以返回0表示成功,返回一正值表示失败。这一失败时的返回值,与传统UNIX系统调用置于errno中的值含义相同。
编译Pthreads程序
在Linux平台上,在编译调用了Pthreads API 的程序时,需要设置gcc -pthread
的编译选项。使用该选项的效果如下:
- 定义
_REENTRANT
预处理宏。这会公开对少数可重入(reentrant)函数的声明。 - 程序会与库
libpthread
进行链接(等价于-lpthread
)。
3. 创建线程
启动程序时,产生的进程只有单条线程,称之为初始(initial)或主(main)线程。本节将讨论其他线程的创建过程。
函数pthread_create()
负责创建一条新线程。
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start)(void *), void *arg);
// Returns 0 on success, or a positive error number on error
新线程会从函数指针start
指向的函数开始执行,并且这个函数的参数由arg
指定(相当于start(arg)
)。调用pthread_create()
的线程会继续执行该调用之后的语句。
参数arg
为void *
类型,这意味着你可以将指向任意类型的指针当作这个函数的参数,甚至在某些简单的情况下,可以用该指针变量直接传递int数据(即(void *)int
)。
start()
的返回值类型为void *
,对其使用方式与arg
相同。这个返回值可以通过后面介绍的pthread_join()
函数获得。
thread
为pthread_t *
类型的输出型参数,在start()
返回之前,会在此处保存这个新线程的线程ID。
标准明确指出,具体的实现无需在新线程开始运行之前就将thread
参数的值赋值完毕,这说明有可能pthread_create()
在返回之前子进程就已经开始运行了。
参数attr
是指向pthread_attr_t
类型的指针,该对象指定了新线程的各种属性。这些属性初学可能并不常用,故不在此做更多介绍。如果将attr
设置为NULL
,那么创建新线程会使用默认的属性。
调用pthread_create()
后,应用程序无从确定系统接着会调度哪一个线程来使用CPU 资源(在多处理器系统中,多个线程可能会在不同CPU上同时执行)。
4. 终止线程
可以用如下的几种方式终止线程的运行:
- 线程运行的
start()
函数执行return语句返回。 - 线程调用
pthread_exit()
- 调用
pthread_cancel()
取消线程 - 任意线程调用
exit()
,或者主线程在main函数中执行了return语句,都会导致进程中的所有线程立即终止。
pthread_exit()
函数将终止调用进程。
#include <pthread.h>
void pthread_exit(void *retval)
调用pthread_exit()
相当于在线程的start()
函数中执行return语句,不同的是,线程可以在其调用的任何函数中调用pthread_exit()
。
参数retval
指定了线程的返回值,这个返回值可以通过pthread_join()
函数获得。retval
所在的空间不应分配在线程栈上,因为随着线程的终止,原来的线程栈所在的位置会被其他线程占用。
如果主线程调用pthread_exit()
,而非调用exit()
或是执行return语句,那么其他线程将继续运行。
5. 线程ID
进程内部的每个线程都有一个唯一的线程ID(Linux实现中,这个线程ID甚至是进程间唯一的)。线程ID会返回给pthread_create()
的调用者,而pthread_self()
可以获得自己的线程ID。
#include <pthread.h>
pthread_t pthread_self();
Linux中将pthread_t
定义为无符号长整型(unsigned long),但注重可移植性的程序必须把它当作不透明的数据类型来看待。
POSIX线程ID与Linux专有的系统调用gettid()
所返回的线程ID 并不相同。POSIX线程ID由线程库实现来负责分配和维护。gettid()
返回的线程ID 是一个由内核(Kernel)分配的数字,类似于进程ID(process ID)
6. 连接(joining)已终止的线程
函数pthread_join()
等待某个线程终止,这个操作类似于waitpid()
。如果这个线程已经终止则立刻返回。这个操作被称为连接(joining)。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
参数thread
标志了要等待的进程的ID。
参数retval
为输出型参数,若其不为空,则会存储等待退出的线程的返回值,因为线程的返回值为void *
类型,所以这里的retval
输出型参数为void **
类型。
若线程并未分离(下一节介绍),则必须由pthread_join()
连接,如果也不连接,则该线程终止后会成为僵尸线程,与僵尸进程的概念类似。除了浪费系统资源以外,僵尸进程若积累过多,则应用再也无法创建线程。
pthread_join()
类似于进程间的waitpid()
,但二者之间也有显著差别:
- 线程之间的关系是对等的(peers)。进程中的任意线程均可以调用
pthread_join()
与该进程的任何其他线程连接。例如,如果线程A 创建线程B,线程B 再创建线程C,那么线程A 可以连接线程C,线程C 也可以连接线程A。这与进程间的层次关系不同,父进程如果使用fork()创建了子进程,那么它也是唯一能够对子进程调用wait()
的进程。调用pthread_create()
创建的新线程与发起调用的线程之间,就没有这样的关系。 - 无法“连接任意线程”(对于进程,则可以通过调用
waitpid(-1, &status, options)
做到这一点),也不能以非阻塞(nonblocking)方式进行连接(类似于设置WHOHANG
标志的waitpid()
)。
无法连接任意线程是刻意设计出来的,由于线程间没有进程间的层次关系,所以连接进程内的任意线程是很不稳定的,例如有可能连接到库函数创建的线程,这会导致很多莫名其妙的BUG。
小练习
下面通过一个小程序实践一下上面的三个函数。
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
void *thread_start(void *arg) {
char *str = (char *)arg;
printf("arg: [%s]\n", str);
pthread_exit((void *)strlen(str));
}
int main() {
pthread_t t;
void *res;
int ret = pthread_create(&t, NULL, &thread_start, "Hello World!");
if(ret != 0) {
printf("pthread_create() call failed!\n");
exit(-1);
}
ret = pthread_join(t, &res);
if(ret != 0) {
printf("pthread_join() call failed!\n");
exit(-1);
}
printf("length: %ld\n", (long)res);
return 0;
}
7. 线程的分离
默认情况下,线程是可连接的(joinable),也就是说,当线程退出时,其他线程可以通过调用pthread_join()
获取其返回状态。有时,程序员并不关心线程的返回状态,只是希望系统在线程终止时能够自动清理并移除之。在这种情况下,可以调用pthread_detach()
并向thread
参数传入指定线程的标识符,将该线程标记为处于分离(detached)状态。
#include <pthread.h>
int pthread_detach(pthread_t thread)
// Returns 0 on success, or a positive error number on error
像下面这样调用可以让线程“自行分离”:
pthread_detach(pthread_self());
一旦线程处于分离状态,就不能再使用pthread_join()
来获取其状态,也无法使其重返“可连接”状态。
1 条评论
这么水的博客我一天可以写50篇,不对100000000000000000000000000000000000000篇