与进程( process)类似1,线程( thread)是允许应用程序并发执行多个任务的一种机制。如图 29-1 所示,一个进程可以包含多个线程。同一程序中的所有线程均会独立执行相同程序,且共享同一份全局内存区域,其中包括初始化数据段( initialized data)、未初始化数据段( uninitialized data),以及堆内存段( heap segment)。 (传统意义上的 UNIX 进程只是多线程程序的一个特例,该进程只包含一个线程。 )
同一进程中的多个线程可以并发执行。在多处理器环境下,多个线程可以同时并行。如果一线程因等待 I/O 操作而遭阻塞,那么其他线程依然可以继续运行。
对于某些应用而言,线程要优于进程。传统 UNIX 通过创建多个进程来实现并行任务。以网络服务器的设计为例,服务器进程(父进程)在接受客户端的连接后,会调用 fork()来创建一个单独的子进程,以处理与客户端的通信(可参考 60.3 节)。采用这种设计,服务器就能同时为多个客户端提供服务。虽然这种方法在很多情境下都屡试不爽,但对于某些应用来说也确实存在如下一些限制。
1.进程间的信息难以共享。由于除去只读代码段外,父子进程并未共享内存,因此必须采用一些进程间通信方式,在进程间进行信息交换。 2.调用 fork()来创建进程的代价相对较高。即便利用 24.2.2 节所描述的写时复制( copy-on-write)技术,仍然需要复制诸如内存页表( page table)和文件描述符表( file descriptor table)之类的多种进程属性,这意味着 fork()调用在时间上的开销依然不菲。 线程解决了上述两个问题。 1.线程之间能够方便、快速地共享信息。只需将数据复制到共享(全局或堆)变量中即可。不过,要避免出现多个线程试图同时修改同一份信息的情况,这需要使用第 30章描述的同步技术。 2.创建线程比创建进程通常要快 10 倍甚至更多。(在 Linux 中,是通过系统调用 clone()来实现线程的)线程的创建之所以较快,是因为调用 fork()创建子进程时所需复制的诸多属性,在线程间本来就是共享的。特别是,既无需采用写时复制来复制内存页,也无需复制页表
除了全局内存之外,线程还共享了一干其他属性(这些属性对于进程而言是全局性的,而并非针对某个特定线程),包括以下内容。
-
进程 ID( process ID)和父进程 ID。
-
进程组 ID 与会话 ID( session ID)。
-
控制终端。
-
进程凭证( process credential)(用户 ID 和组 ID )。
-
打开的文件描述符。
-
由 fcntl()创建的记录锁( record lock)。
-
信号( signal)处置。
-
文件系统的相关信息:文件权限掩码( umask)、当前工作目录和根目录。
-
间隔定时器( setitimer())和 POSIX 定时器( timer_create())。
-
系统 V( system V)信号量撤销( undo, semadj)值( 47.8 节)。
-
资源限制( resource limit)。
-
CPU 时间消耗(由 times()返回)。
-
资源消耗(由 getrusage()返回)。
-
nice 值(由 setpriority()和 nice()设置)。
各线程所独有的属性,如下列出了其中一部分。
-
线程 ID( thread ID, 29.5 节)。
-
信号掩码( signal mask)。
-
线程特有数据( 31.3 节)。
-
备选信号栈( sigaltstack())。
-
errno 变量。
-
浮点型( floating-point)环境(见 fenv(3))。
-
实时调度策略( real-time scheduling policy)和优先级( 35.2 节和 35.3 节)。
-
CPU 亲和力( affinity, Linux 所特有, 35.4 节将加以描述)。
-
能力( capability, Linux 所特有,第 39 章将加以描述)。
-
栈,本地变量和函数的调用链接( linkage)信息。
29.2 Pthreads API 的详细背景
20 世纪 80 年代末、 90 年代初,存在着数种不同的线程接口。 1995 年 POSIX.1c 对 POSIX 线程 API 进行了标准化,该标准后来为 SUSv3 所接纳。 有几个概念贯穿整个 Pthreads API,在深入探讨 API 之前,将简单予以介绍。
线程数据类型(Pthreads data type)
Pthreads API 定义了一干数据类型,表 29-1 列出了其中的一部分。后续章节会对这些数据类型中的绝大部分加以描述
SUSv3 并未规定如何实现这些数据类型,可移植的程序应将其视为“不透明”数据。亦即,程序应避免对此类数据类型变量的结构或内容产生任何依赖。尤其是,不能使用 C 语言的比较操作符( ==)去比较这些类型的变量。
线程和 errno
在传统 UNIX API 中, errno 是一全局整型变量。然而,这无法满足多线程程序的需要。如果线程调用的函数通过全局 errno 返回错误时,会与其他发起函数调用并检查 errno 的线程混淆在一起。换言之,这将引发竞争条件( race condition)。
因此,在多线程程序中,每个线程都有属于自己的 errno。 在 Linux 中, 线程特有 errno 的实现方式与大多数 UNIX 实现相类似: 将 errno 定义为一个宏,可展开为函数调用,该函数返回一个可修改的左值( lvalue),且为每个线程所独有。(因为左值可以修改,多线程程序依然能以 errno=value 的方式对 errno 赋值。 )一言以蔽之, errno 机制在保留传统 UNIX API 报错方式的同时,也适应了多线程环境
Pthreads 函数返回值
从系统调用和库函数中返回状态,传统的做法是:返回 0 表示成功,返回-1 表示失败,并设置 errno 以标识错误原因。 Pthreads API 则反其道而行之。所有 Pthreads 函数均以返回 0 表示成功,返回一正值表示失败。这一失败时的返回值,与传统 UNIX 系统调用置于 errno 中的值含义相同。 由于多线程程序对 errno 的每次引用都会带来函数调用的开销,因此,本书示例并不会直接将 Pthreads 函数的返回值赋给 errno,而是使用一个中间变量,并利用自己实现的诊断函数errExitEN()( 3.5.2 节),如下所示
编译 Pthreads 程序
在 Linux 平台上, 在编译调用了 Pthreads API 的程序时, 需要设置 cc -pthread 的编译选项。 使用该选项的效果如下。 1.定义_REENTRANT 预处理宏。这会公开对少数可重入( reentrant)函数的声明。 2.程序会与库 libpthread 进行链接(等价于-lpthread)。
29.3 创建线程
启动程序时,产生的进程只有单条线程,称之为初始( initial)或主( main)线程。本节将讨论其他线程的创建过程。
函数 pthread_create()负责创建一条新线程。 新线程通过调用带有参数 arg 的函数 start (即 start(arg)) 而开始执行。 调用 pthread_create()的线程会继续执行该调用之后的语句。(如 28.2 节所述, 这一行为与 glibc 库对系统调用 clone()的包装函数行为相同。 )
将参数 arg 声明为 void*
类型,意味着可以将指向任意对象的指针传递给 start()函数。一般情况下, arg 指向一个全局或堆变量,也可将其置为 NULL。如果需要向 start()传递多个参数,可以将 arg 指向一个结构,该结构的各个字段则对应于待传递的参数。通过审慎的类型强制转换, arg 甚至可以传递 int 类型的值。 严格说来,对于 int 与 void*
之间相互强制转换的后果, C 语言标准并未加以定义。不过,大部分 C 语言编译器允许这样的操作,并且也能达成预期的目的,即 int j == (int) ((void*) j)
。 start()的返回值类型为 void*
,对其使用方式与参数 arg 相同。对后续 pthread_join()函数的描述中,将论及对该返回值的使用方式。 将经强制转换的整型数作为线程 start 函数的返回值时,必须小心谨慎。原因在于,取消线程(见第 32 章)时的返回值 PTHREAD_CANCELED,通常是由实现所定义的整型值,再经强制转换为 void*。
若线程某甲的 start 函数将此整型值返回给正在执行 pthread_join()操作的线程某乙,某乙会误认为某甲遭到了取消。应用如果采用了线程取消技术并选择将 start 函数的返回值强制转换为整型,那么就必须确保线程正常结束时的返回值与当前 Pthreads 实现中的PTHREAD_CANCELED 不同。如欲保证程序的可移植性,则在任何将要运行该应用的实现中,
正常退出线程的返回值应不同于相应的 PTHREAD_CANCELED 值。 参数 thread 指向 pthread_t 类型的缓冲区,在 pthread_create()返回前,会在此保存一个该线程的唯一标识。后续的 Pthreads 函数将使用该标识来引用此线程。 SUSv3 明确指出,在新线程开始执行之前,实现无需对 thread 参数所指向的缓冲区进行初始化,即新线程可能会在 pthread_create()返回给调用者之前已经开始运行。如新线程需要获取自己的线程 ID,则只能使用pthread_self()方法。
参数 attr 是指向 pthread_attr_t 对象的指针,该对象指定了新线程的各种属性。 29.8 节将述及其中的部分属性。如果将 attr 设置为 NULL,那么创建新线程时将使用各种默认属性,本书的大部分示例程序都采用这一做法。
调用 pthread_create()后,应用程序无从确定系统接着会调度哪一个线程来使用 CPU 资源。程序如隐含了对特定调度顺序的依赖,则无疑会对 24.4 节所述的竞争条件打开方便之门。如果对执行顺序确有强制要求,那么就必须采用第 30 章所描述的同步技术
29.4 终止线程
可以如下方式终止线程的运行。
1.线程 start 函数执行 return 语句并返回指定值。
2.线程调用 pthread_exit()(详见后述)。
3.调用 pthread_cancel()取消线程(在 32.1 节讨论)。
4.任意线程调用了 exit(),或者主线程执行了 return 语句(在 main()函数中),都会导致进程中的所有线程立即终止。 pthread_exit()函数将终止调用线程,且其返回值可由另一线程通过调用 pthread_join()来获取。
调用 pthread_exit()相当于在线程的 start 函数中执行 return,不同之处在于,可在线程 start函数所调用的任意函数中调用 pthread_exit() 。 参数 retval 指定了线程的返回值。 Retval 所指向的内容不应分配于线程栈中,因为线程终止后,将无法确定线程栈的内容是否有效。(例如,系统可能会立刻将该进程虚拟内存的这片区域重新分配,供一个新的线程栈使用。)出于同样的理由,也不应在线程栈中分配线程 start 函数的返回值。 如果主线程调用了 pthread_exit(),而非调用 exit()或是执行 return 语句,那么其他线程将继续运行。
29.5 线程 ID(Thread ID)
进程内部的每个线程都有一个唯一标识,称为线程 ID。线程 ID 会返回给 pthread_create()的调用者,一个线程可以通过 pthread_self()来获取自己的线程 ID。
线程 ID 在应用程序中非常有用,原因如下。 1.不同的Pthreads 函数用线程 ID 来标识要操作的目标线程。这些函数包括 pthread_join()、pthread_detach()、 pthread_cancel()和 pthread_kill()等,后续章节将会加以讨论。 2.在一些应用程序中,以特定线程的线程 ID 作为动态数据结构的标签,这颇有用处,既可用来识别某个数据结构的创建者或属主线程,又可以确定随后对该数据结构执行操作的具体线程。 函数 pthread_equal()可检查两个线程的 ID 是否相同。
例如, 为了检查调用线程的线程 ID 与保存于变量 t1 中的线程 ID 是否一致, 可以编写如下代码: 因为必须将 pthread_t 作为一种不透明的数据类型加以对待, 所以函数 pthread_equal()是必须的。 Linux 将 pthread_t 定义为无符号长整型( unsigned long),但在其他实现中,则有可能是一个指针或结构。
SUSv3 并未要求将 pthread_t 实现为一个标量( scalar)类型,该类型也可以是一个结构。因此,下列显示线程 ID 的代码实例并不具有可移植性(尽管该实例在包括 Linux 在内的许多实现上均可正常运行,而且有时在调试程序时还很实用)。
在 Linux 的线程实现中,线程 ID 在所有进程中都是唯一的。不过在其他实现中则未必如此, SUSv3 特别指出,应用程序若使用线程 ID 来标识其他进程的线程,其可移植性将无法得到保证。此外,在对已终止线程施以pthread_join(),或者在已分离( detached)线程退出后,实现可以复用该线程的线程 ID。 (下一节和 29.7 节将分别解释 pthread_join()和线程的分离。 )
29.6 连接(joining)已终止的线程
函数 pthread_join()等待由 thread 标识的线程终止。 (如果线程已经终止, pthread_join()会立即返回)。这种操作被称为连接(joining)
若 retval 为一非空指针,将会保存线程终止时返回值的拷贝,该返回值亦即线程调用return 或 pthred_exit()时所指定的值。 如向 pthread_join()传入一个之前已然连接过的线程 ID,将会导致无法预知的行为。例如,相同的线程 ID 在参与一次连接后恰好为另一新建线程所重用,再度连接的可能就是这个新线程。 若线程并未分离(detached,见 29.7 节),则必须使用 ptherad_join()来进行连接。如果未能连接,那么线程终止时将产生僵尸线程,与僵尸进程(zombie process)的概念相类似(参考 26.2节)。
除了浪费系统资源以外,僵尸线程若累积过多,应用将再也无法创建新的线程。 pthread_join()执行的功能类似于针对进程的 waitpid()调用,不过二者之间存在一些显著差别。 1.线程之间的关系是对等的( peers)。进程中的任意线程均可以调用 pthread_join()与该进程的任何其他线程连接起来。 例如, 如果线程 A 创建线程 B, 线程 B 再创建线程 C,那么线程 A 可以连接线程 C, 线程 C 也可以连接线程 A。 这与进程间的层次关系不同,父进程如果使用 fork()创建了子进程,那么它也是唯一能够对子进程调用 wait()的进程。调用 pthread_create()创建的新线程与发起调用的线程之间,就没有这样的关系。 2.无法“连接任意线程”(对于进程,则可以通过调用 waitpid(-1, &status, options)做到这一点),也不能以非阻塞( nonblocking)方式进行连接(类似于设置 WHOHANG 标志的 waitpid())。使用条件( condition)变量可以实现类似的功能, 30.2.4 节会给出示例。
29.7 线程的分离
默认情况下,线程是可连接的(joinable),也就是说,当线程退出时,其他线程可以通过调用 pthread_join()获取其返回状态。有时,程序员并不关心线程的返回状态,只是希望系统在线程终止时能够自动清理并移除之。在这种情况下,可以调用 pthread_detach()并向 thread 参数传入指定线程的标识符,将该线程标记为处于分离状态。
如下例所示,使用 pthread_detach(),线程可以自行分离:
pthread_detach(pthread_self())
一旦线程处于分离状态,就不能再使用 pthread_join()来获取其状态,也无法使其重返“可连接”状态。 其他线程调用了 exit(),或是主线程执行 return 语句时,即便遭到分离的线程也还是会受到影响。此时,不管线程处于可连接状态还是已分离状态,进程的所有线程会立即终止。
换言之, pthread_detach()只是控制线程终止之后所发生的事情,而非何时或如何终止线程。
29.8 线程属性
前面已然提及 pthread_create()中类型为 pthread_attr_t 的 attr 参数, 可利用其在创建线程时指定新线程的属性。本书无意深入这些属性的细节,也不会将操作 pthread_attr_t 对象的各种 Pthreads 函数原型一一列出, 只会点出如下之类的一些属性:
线程栈的位置和大小、
线程调度策略和优先级
以及线程是否处于可连接或分离状态。 作为线程属性的使用示例,程序清单 29-2 中的代码创建了一个新线程,该线程刚一创建即遭分离(而非之后再调用 pthread_detach())。这段代码首先以缺省值对线程属性结构进行初始化,接着为创建分离线程而设置属性,最后再以此线程属性结构来创建新线程。线程一旦创建,就无需再保留该属性对象,故而程序将其销毁
29.9 线程 VS 进程
将应用程序实现为一组线程还是进程?本节将简单考虑一下可能影响这一决定的部分因素。先从多线程方法的优点开始。 1.线程间的数据共享很简单。相形之下,进程间的数据共享需要更多的投入。 (例如,创建共享内存段或者使用管道 pipe)。 2.创建线程要快于创建进程。线程间的上下文切换( context-switch),其消耗时间一般也比进程要短。
线程相对于进程的一些缺点如下所示。
1.多线程编程时,需要确保调用线程安全( thread-safe)的函数,或者以线程安全的方式来调用函数。 多进程应用则无需关注这些。
2.某个线程中的 bug(例如,通过一个错误的指针来修改内存)可能会危及该进程的所有线程, 因为它们共享着相同的地址空间和其他属性。 相比之下, 进程间的隔离更彻底。
3.每个线程都在争用宿主进程( host process)中有限的虚拟地址空间。特别是,一旦每个线程栈以及线程特有数据(或线程本地存储)消耗掉进程虚拟地址空间的一部分,则后续线程将无缘使用这些区域。虽然有效地址空间很大,但当进程分配大量线程,亦或线程使用大量内存时,这一因素的限制作用也就突显出来。与之相反,每个进程都可以使用全部的有效虚拟内存,仅受制于实际内存和交换( swap)空间。
影响选择的还有如下几点。 1.在多线程应用中处理信号,需要小心设计。 (作为通则,一般建议在多线程程序中避免使用信号。 )关于线程与信号, 33.2 节会做深入讨论。 2.在多线程应用中,所有线程必须运行同一个程序(尽管可能是位于不同函数中)。对于多进程应用,不同的进程可以运行不同的程序。 3.除了数据,线程还可以共享某些其他信息(例如,文件描述符、信号处置、当前工作目录,以及用户 ID 和组 ID)。优劣之判,视应用而定。