线程概述
与process类似,thread是允许应用程序并发执行多个任务的一种机制。
同一程序中的所有线程均会独立执行相同程序,且共享同一份全局内存区域,其中包括:
- 初始化数据段(initialized data)
- 未初始化数据段(uninitialized data)
- 堆内存段( heap segment)
(传统意义上的UNIX进程只是多线程程序的一个特例,该进程只包含一个线程)
同一进程的多个线程可以并发执行。在多个处理器环境下,多个线程还可以同时并行。
比如,一个线程因为等待I/O而遭阻塞,其他线程仍然可以继续运行。
相比于之下,多进程程序存在如下限制:
- 进程间信息难以共享。
- 除去只读代码外,父子进程并未共享内存,因此必须采用一些进程间通信方式,简称IPC方式
- 调用
fork()
创建进程的代价相对较高。- 即便利用写时复制计数,仍然需要复制诸如内存页表、文件描述符之类的多种进程属性,这意味着
fork()
在时间上的开销依然不菲。
- 即便利用写时复制计数,仍然需要复制诸如内存页表、文件描述符之类的多种进程属性,这意味着
线程解决了上面两个问题。
- 线程之间能够方便、快速地共享信息。
- 只需要将数据复制到共享(全局或堆)变量中即可。
- 不过,要避免多个线程试图同时修改同一份信息的情况,需要采用同步技术。
- 创建线程比创建进程的速度要快10倍甚至更多。
- 在Linux中,是通过
clone()
来实现线程的 - 线程之所以创建较快,是因为调用
fork()
需要复制进程的诸多属性,而线程间是共享的,无需复制内存页、页表。
- 在Linux中,是通过
除了全局内存外,线程还共享以下属性:
- 进程ID(process ID)和父进程ID。
- 进程组ID(session ID)。
- 控制终端。
- 进程凭证(process credential)(用户ID和组ID)。
- 打开的文件描述符。
- 由
fcntl()
创建的记录锁(record lock)。 - 信号(signal)处置.
- 文件系统的相关信息:文件权限掩码( umask)、当前工作目录和根目录。
- 间隔定时器(
setitimer()
)和POSIX定时器(timer_create()
)。 - System V信号量撤销(undo, semadj)值。
- 资源限制(resource limit)。
- CPU时间消耗(由
times()
返回)。 - 资源消耗(由
getrusage()
返回)。 - nice值(由
setpriority()
和nice()
返回)。
各线程独有的属性,列出其中一部分:
- 线程ID( thread ID)
- 信号掩码(signal mask)
- 线程特有数据
- 备选信号栈(
signalstack()
)。 errno
变量。- 浮点型(floating-point)环境。
- 实时调度策略(real-time scheduling policy)和优先级。
- CPU亲和力(affinity,Linux所特有)
- 能力(capability, Linux所特有)
- 栈,本地变量和函数调用链接(linkage)信息。
所有线程栈均驻留于同一虚拟地址空间。这意味着,利用一个合适的指针,各线程可以在对方栈中相互共享数据,这种方法偶尔能派上用场,但由于局部变量的状态有效与否取决于其所驻留栈帧的声明周期,故而需要谨慎处理这一问题。(当函数返回时,该函数栈帧所占用的内存区可能为后续的函数调用所重新使用。如果线程中止,那么新线程有可能会对已经中止线程的栈所占用的内存空间重新加以利用)。若无法正确处理,由此而产生的bug将难以捕获。
Pthreads API概念
POSIX 线程API为SUSv3所接纳。
先介绍贯穿于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 | 一次性初始化控制上下文 |
pthread_attr_t | 线程的属性对象 |
SUSv3并未规定如何实现这些数据结构,可移植的程序应将其视为“不透明”数据。
亦即,程序应避免对此类数据类型变量的结构或内容产生任何依赖。尤其是,不能使用C语言的比较操作符(==)去比较这些类型的变量。
线程和errno
在传统UNIX API中,errno是一个全局整型变量。然而这无法满足多线程程序的需要,这回引发竞争条件。因此,多线程程序中,每个线程都有属于自己的errno
。在Linux中,线程特有的errno
的实现方式与大多数UNIX实现相类似:
将errno
定义为一个宏,可展开为函数调用,该函数返回一个可修改的左值,且为每个线程所独有。(因为左值可以修改,多线程程序仍然能够以errno=value
的方式对errno
赋值)。
如今,需要声明
errno
的程序必须包含<errno.h>
,以启用对errno
的线程级实现。
Pthreads函数返回值
从系统调用和库函数中返回状态,传统做法是:返回0表示成功,返回-1表示失败,并置errno
以标识错误。
Pthreads API反其道而行之。所有Pthreads API函数返回0表示成功,返回一正值表示失败。这一失败时的返回值,与传统UNIX系统调用置于errno
中的值含义相同。
由于多线程对errno
的每次引用都会带来函数调用的开销,因此,本书示例不会直接将Pthreads函数的返回值赋给errno
,而是使用一个中间变量 ,并利用自己实现的诊断函数errExitEN()
,如下所示:
pthread_t *thread;
int s;
s = pthread_create(&thread, NULL, func, &arg);
if( s!= 0)
errExitEN(s, "pthread_create);
编译Pthreads程序
在Linux平台上,在编译调用了Pthreads API的程序时,需要设置 cc -pthread
的编译选项。
该选项的效果如下:
- 定义
_REENTRANT
预处理宏。这回公开对少数可重入(reentrant)函数的声明。 - 程序会与库
libpthread
进行链接(等价于-lpthread
)
Pthreads API
创建线程
启动程序时,产生的进程只有单条线程,称之为初始(initial)或主(main)线程。
#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)
而开始执行。
调用pthread_create()
的线程会继续执行该调用之后的语句。
-
参数start和arg
- 将参数
arg
声明为void *
类型,意味着可以通过将指向任意对象的指针传递给start()
函数。一般情况下,arg
指向一个全局或堆变量,也可将其置位NULL
。 如果需要向start()
传递多个参数,可以将arg
指向一个结构。通过审慎的类型强制转换,arg
甚至可以传递int
类型的值。 -
严格来说,对于
int
和void *
间相互强制转换的后果,C语言标准并未加以定义。不过大多数C语言编译器允许这样的操作,并且也能达成预期的目的,即int j == (int)((void*)j)
。 - 将经过强制转换的整型数作为线程
start
函数的返回值时,必须小心谨慎。原因在于,取消线程时的返回值PTHREAD_CNACELLED
通常是由实现所定义的整型值,再经强制转换为void*
。若线程某甲的start
将此整型值返回给正在执行pthread_join()
操作的线程某乙,某乙会误认为某甲遭到了取消。
应用如果采用了线程取消计数并选择将start
函数的返回值强制转换为整型,那么就必须确保线程正常结束时的返回值与当前Pthreads实现中的PTHREAD_CANCELLED
不同。如欲保证程序的可移植性,则在任何将要运行该应用的实现中,正常退出线程的返回值应不同于PTHREAD_CANCELLED
值。
- 将参数
-
参数thread
- 参数
thread
指向pthread_t
类型的缓冲区,在pthread_create()
返回前,会在此保存一个该线程的唯一标识,后续的Pthreads函数将使用该标识来引用此线程。 -
SUSv3曾明确指出,在新线程开始执行之前,实现无需对
thread
参数所指向的缓冲区进行初始化,即线程可能会在pthread_create()
返回之前就已经开始运行,如果新线程需要获取自己的线程ID,则只能使用pthread_self()
。
- 参数
-
参数attr
- 参数
attr
是指向pthread_attr_t
对象的指针,该对象指定了新线程的各种属性。如果将attr
设置为NULL
,那么创建的新线程将使用各种默认属性。
- 参数
调用pthread_create()
后,应用程序无从确定系统接着会调度哪一个线程来使用CPU资源。程序如隐含了对特定调度顺序的依赖,会导致竞争条件。如果对执行顺序有强制要求,需要使用同步技术。
终止线程
可以如下方式终止线程的运行:
- 线程
start
函数执行return
并返回指定值。 - 线程调用
pthread_exit()
。 - 调用
pthread_cancelled()
取消线程。 - 任意线程调用了
exit()
,或者主线程执行了return
语句(在main()
函数中) ,都会导致进程中所有线程立即终止。
pthread_exit()
函数将终止调用线程,且其返回值可由另一线程通过调用pthread_join()
来获取。
#include <pthread.h>
void pthread_exit(void *retval);
-
调用
pthread_exit()
相当于在线程的start
函数中执行return
,不同之处在于,可在线程start
函数所调用的任意函数中调用pthread_exit()
。 -
参数
retval
指定了线程的返回值。retval
所指向的内容不应分配于线程栈中,因为线程终止后,将无法确定线程栈的内容是否有效。 -
出于同样的理由,也不应在线程栈中分配线程
start
函数的返回值。 -
如果主线程调用了
pthread_exit()
,而非调用exit()
或是执行return
语句,那么其他线程将继续运行。
线程ID
进程内部的每个线程都有一个唯一标识,称为线程ID。线程ID会返回给pthread_create()
调用者,一个线程可以通过pthread_self()
来获取自己的线程ID。
#incldue <pthread.h>
pthread_t pthread_self(void);
//Return the thread ID of the calling thread.
线程ID的用途:
- 不同的Pthreads函数利用线程ID来标识要操作的目标线程。
- 包括
pthread_join()
、pthread_detach()
、pthread_cancel()
和pthread_kill()
等。
- 包括
- 在一些应用程序中,以特定的线程ID作为动态数据结构的标签,这颇有用途,既可以用来识别某个数据结构的创建者或属主进程,又可以确定随后对该数据节后执行操作的具体线程。
函数pthread_equal()
可检查两个线程的ID是否相同。
#include <pthread.h>
int pthread_equal(pthread_t t1, pthread_t t2);
//Returns nonzero value if t1 and t2 are equal, otherwise 0;
例如,为了检查调用线程的线程ID与保存与变量t1中的线程ID是否一致,可以这样编写:
if(pthread_equal(tid, pthread_self()))
printf("tid matches self\n);
因为必须将pthread_t
作为一种不透明的数据类型加以对待,所以函数pthread_equal()
是必须的。Linux将pthread_t
定义为无符号长整型(unsigned long),但在其他实现中,则有可能是一个指针或结构。
在NPTL中,
pthread_t
实际上是一个经强制转化而为无符号长整型的指针。
SUSv3并未要求将
pthread_t
实现为一个标量类型,该类型也可以是一个结构。因此下列显示线程ID的代码实例并不具有可移植性(尽管在包括Linux在内的许多实现上均可正常运行,而且有时调试程序还很实用。)
pthread_t thr;
printf("Thread ID = %ld\n", (long)thr); //WRONG!
在Linux的线程实现中,线程ID在所有进程中都是唯一的。不过在其他实现中未必如此。
在对已终止线程施以pthread_join
,或者在已分离(detached)线程退出后,实现可以复用该线程的线程ID。
POSIX线程ID与Linux专有的系统调用
getpid()
所返回的线程ID并不相同。POSIX线程ID由线程库实现来负责分配和维护。getpid()
返回的线程ID是一个由内核(Kernel)分配的数字,类似于进程ID(process ID)。虽然在Linux NPTL线程实现中,每个POSIX线程都对应一个唯一的内核线程ID,但应用程序一般无需了解内核线程ID(况且,如果程序依赖于此信息 ,也将无法移植。)
连接(joining)已终止的线程
函数pthread_join()
等待由thread标识的线程终止。(如果线程已经终止,pthread_join()
会立即返回)。这种操作被称为连接(joining)。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
//Returns 0 on success, or a positive error number on error.
若retval
为一非空指针,将会保存线程终止时返回值的拷贝,该返回值亦即线程调用return
或pthread_exit()
时所指定的值。
如向pthread_join()
传入一个之前已经连接过的线程ID,将会导致无法预知的行为。例如,相同的线程ID在参与一次连接后恰好为另一新线程所重用,再度连接的可能就是这个新线程。
若线程并未分离( detached),则必须使用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()
)。使用条件(condition)变量可以实现类似的功能。
- 例如,如果线程A创建线程B,线程B再创建线程C,那么线程A可以连接线程C,线程C也可以连接线程A,这与进程间的层次关系不同。父进程如果使用
限制
pthread_join()
只能连接特定线程ID,其用意在于:程序应只能连接它所“知道的”线程,线程之间并无层次关系,如果听任“与任意线程连接”的操作发生,那么“任意”线程就可以包括由库函数私自创建的线程,从而带来问题,结果是,函数库的获取线程返回状态时将不再能与该线程连接,只会一错再错,试图连接一个已经连接过的线程ID。换言之,“连接任意线程”的操作与模块化的程序设计理念背道而驰。
程序示例
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void *threadFunc(void *arg)
{
char *s = (char *)arg;
printf("%s", s);
return (void *)strlen(s);
}
int main(void)
{
pthread_t t1;
void *res;
int s;
s = pthread_create(&t1, NULL, threadFunc, "Hello world\n");
if (s != 0) {
fprintf(stderr, "error\n");
exit(EXIT_FAILURE);
}
printf("Message from main()\n");
s = pthread_join(t1, &res);
if (s != 0) {
fprintf(stderr, "error s!= 0\n");
exit(EXIT_FAILURE);
}
printf("Thread returned %ld\n", (long)res);
exit(EXIT_SUCCESS);
}
$gcc 1.c -o 1 -lpthread
$./1
Message from main()
Hello world
Thread return 12
线程的分离
默认情况下,线程是可连接的(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_deatch()
,线程可以自动分离。
pthread_detach(pthread_self());
一旦线程处于分离状态,就不能再使用pthread_join()
来获取其状态,也无法使其重返“可连接”状态。
其他线程调用了exit()
,或是主线程执行return
语句时,即使遭到分离的线程也还是会受到影响。
此时,不管线程处于可连接还是已分离状态,进程的所有线程会立即终止,换言之,pthread_detach()
只是控制线程终止之后发生的事,而非何时或如何终止线程。
线程属性
这里只点出如下之类的一些属性:线程栈的位置和大小、线程调度策略和优先级,以及线程是否处于可连接或分离状态。
下列代码示例创建了一个新线程,该线程刚一创建就遭到分离(而非之后再调用pthread_detach()
)。这段代码首先以缺省值对线程属性结构进行初始化,接着为创建分离线程而设置属性,最后再以此线程属性结构来创建新线程。线程一旦创建,就无需再保留该属性对象,故而程序将其销毁。
示例程序
pthread_t thr;
pthread_attr_t attr;
int s;
s = pthread_attr_init(&attr);
if (s != 0) errExitEN(s, "pthread_attr_init");
s = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
if (s != 0) errExitEN(s, "pthread_attr_setdetachstate");
s = pthread_create(&thr, &attr, threadFunc, (void*)1);
if (s != 0) errExitEN(s, "pthread_create");
s = pthread_attr_destory(&attr);
if (s != 0) errExitEN(s, "pthread_attr_destory");
线程VS进程
将应用程序实现为一组线程还是进程?这里简单考虑一些可能影响这一决定的部分因素。
多线程的优点:
- 线程间数据共享很简单。
- 相形之下,进程间的数据共享需要更多的投入。(例如,创建共享内存段或者使用管道pipe)。
- 创建线程要快于创建进程。
- 线程间的上下文切换( context-switch)。其消耗时间一般也比进程要短。
线程相比进程的缺点:
- 多线程编程时,需要确保调用线程安全(thread-safe)的函数,或者以线程安全的方式来调用函数。多进程则无需关注这些。
- 某个线程中的bug(例如,通过一个错误的指针来修改内存)可能会危及该进程的所有线程,因为它们共享着相同的地址空间和其他属性。相比之下,进程间的隔离更彻底。
- 每个线程都在争用宿主进程(host process)中有限的虚拟地址空间。特别是,一旦每个线程栈以及线程特有数据(或线程本地存储)消耗掉进程虚拟地址空间的一部分,则后续线程将无缘使用这些区域。虽然有效地址空间很大(例如,在x86-32平台上通常有3GB),但当进程分配大量线程,亦或线程使用大量内存时。这一因素的限制作用也就突显出来。与之相反,每个进程都可以使用全部的有效虚拟内存,仅受制于实际内存和交换(swap)空间。
影响选择的还有如下几点:
- 在多线程应用中处理信号,需要小心设计。(作为通则,一般建议在多线程程序中避免使用信号。)
- 在多线程应用中,所有线程必须运行同一个程序(尽管可能是位于不用函数中)。对于多进程应用,不同的进程可以运行不用的程序。
- 除了数据,线程还可以共享某些其他信息(例如,文件描述符、信号处置、当前工作目录,以及用户ID和组ID。)优劣之判,视应用而定。
总结
- 线程与进程的关键区别在于,线程比进程更容易共享信息。
- 这也是许多应用舍进程而取线程的主要原因。
- 线程还可以提供更好的性能。
- 但是,在程序设计的进程/线程之争中,这往往不是决定因素、