在传统的unix模型中,当一个进程需要另一个实体来完成某项任务时,它就 fork
一个子进程出来处理,比如在一个网络服务器程序中,父进程 accept
一个连接,然后 fork
一个子进程,由该子进程处理与连接对端的客户端之间的通信。
尽管这种范式很久以来一直用得很好,但是 fork
调用却存在一些问题:
fork
是昂贵的。 fork
要把父进程的内存映像复制到子进程中,并在子进程中复制所有描述符。当前的实现使用写时复制(COW)的技术,来避免子进程切实需要自己的副本之前把父进程的数据复制到子进程。然而即使有这样的优化措施, fork
仍然是昂贵的。 fork
返回之后,复制进程需要通过进程间通信(IPC)来传递信息。从父进程传递信息到子进程相当容易,因为子进程将从父进程的数据空间和描述符的副本开始运行,然而从子进程往父进程传递消息却比较费力。 线程有助于解决这两个问题。线程有时称为轻量进程(lightweight process),因为线程比进程更轻量。也就是说,线程的创建可能比进程的创建快10~100倍。同一进程内的所有线程共享相同的全局内存。这使得线程之间易于共享信息,然而伴随这种简易性而来的却是同步(synchronization)问题。
统一进程内的所有线程除了共享全局变量外还共享:
不过每个线程有各自的:
errno
本文中讲述的是POSIX线程,也成为 Pthread
。POSIX线程作为POSIX.1c标准的一部分在1995年得到标准化,大多数unix版本支持这类线程。我们将看到所有的 Pthread
函数都以 ptread_
开头。
当一个程序由exec启动执行时,称为初始线程(initial thread)或者主线程(main thread)的单个线程就被创建了,其余线程则由 ptread_create
函数创建。
pthread_t pthread_create(pthread_t *tid, const pthread_attr_t *attr, void *(*func)(void *), void * arg); 复制代码
一个进程内的每个线程都由一个线程ID(thread ID)标识,其数据类型为 pthread_t
(往往都是 unsigned int)。如果新的线程创建成功,其ID就通过 tid
指针返回。
每个线程都有许多属性(attribute):优先级、初始栈大小、是否为守护线程等等。我们可以在创建线程时通过初始化一个取代默认设置的 pthread_attr_t
变量指定这些属性。通常情况我们采用默认的设置,这时我们把 attr
参数指定为空指针。
创建一个线程时我们最后指定的参数是由该线程执行的函数及其参数。该线程通过调用该函数开始执行,然后显式(调用 pthread_exit
)或者隐式(函数返回)地终止。该函数的地址由 func
参数指定,该函数的唯一调用参数是指针 arg
。如果我们需要给该函数传递多个参数,我们就得把它们打包成一个结构,然后把这个结构的地址作为单个参数传递给 func
。
注意 func
和 arg
的声明, func
所指函数作为参数接受一个通用指针(void *),又作为返回值返回一个通用指针(void *)。这时的我们可以把一个指针(它指向我们期望的任何内容)传递给一个线程,又允许线程返回一个指针(它同样指向我们所期望的任何内容)。
通常情况下Ptread函数的返回值成功时为0,出错时为某个非0值。与套接字及大多数系统调用出错时返回-1并置 errno
为某个正值的做法不同的是,Pthread函数出错时作为函数返回值返回正值错误指示。举个例子,如果 pthread_create
因在线程数目上超过某个系统限制而不能创建新线程,函数返回值将是 EAGAIN
。Pthread函数不设置 errno
。
我们可以通过调用 pthread_join
等待一个给定线程终止。对比线程和unix进程, pthread_create
类似于 fork
, pthread_join
类似于 waitpid
。
pthread_t pthread_join(pthread_t *tid, void ** status); 复制代码
我们必须制定要等待的线程的 tid
。不幸的是,Pthread没有办法等待任意一个线程(类似在 waitpid
中制定参数为-1)。如果 status
指针非空,来自所等待的线程的返回值(一个指向某个对象的指针)将存入 status
所指向的位置。
每个线程都有一个在进程内标识自身的ID。线程ID由 pthread_create
返回,而我们可以在 pthread_join
中使用它。每个线程可以通过 ptread_self
获取自身的线程ID。
pthread_t pthread_self(void); 复制代码
对比unix线程, pthread_self
类似于 getpid
。
一个线程或者是可汇合的(joinable,默认值),或者是脱离的(detached)。当一个可汇合的线程终止时,它的线程ID和退出状态将留存到另一个线程对它调用 pthread_join
。脱离的线程却像守护进程,当它们终止时,所有相关资源都将被释放,我们不能等待它们终止。
pthread_detach
函数把指定的线程变为脱离状态。
pthread_t pthread_detach(pthread_t tid); 复制代码
让一个线程终止的方法之一是调用 pthread_exit
。
void pthread_exit(void *status); 复制代码
如果该线程未曾脱离,它的线程ID和退出状态将一直留存到调用进程内某个其他线程对它调用 pthread_join
。
指针 status
不能指向局部于调用线程的对象,因为线程终止时,这样的对象也会消失。
让一个线程终止的另外两个方法是:
pthread_create exit
线程编程称为并发编程(concurrent programming)或者并行编程(parallel programming),因为多个线程可以并发(或者并行)地运行且访问相同的变量。在并发编程中更改同一个变量时可能会产生同步问题,其解决办法是使用一个互斥锁(mutex,表示mutual exclusion)保护共享变量;访问该变量的前提条件是持有该互斥锁。按照Pthread,互斥锁是类型为 pthread_mutex_t
的变量。我们使用以下两个函数为一个互斥锁上锁和解锁。
int pthread_mutex_lock(pthread_mutex_t *mptr); int pthread_mutex_unlock(pthread_mutex_t *mptr); 复制代码
如果试图上锁一个已被另外某个线程锁住的互斥锁,本线程将会被阻塞,直到该互斥锁被解锁为止。
如果某个互斥锁变量是静态分配的,我们就必须把它初始化为常值 PTHREAD_MUTEX_INITIALIZER
。如果我们在共享内存区中分配一个互斥锁,那么必须通过调用 pthread_mutext_init
函数在运行时将其初始化。
以下是一个利用互斥锁操作计数器的例子:
#include <stdio.h> #include <pthread.h> #define NLOOP 5000 int counter; /* 由线程进行递增操作 */ pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER; void *doit(void *); int main(int argc, char **argv) { pthread_t tidA, tidB; pthread_create(&tidA, NULL, &doit, NULL); pthread_create(&tidB, NULL, &doit, NULL); /* 等待线程退出 */ pthread_join(tidA, NULL); pthread_join(tidB, NULL); exit(0); } void *doit(void *vptr) { int i, val; /* 先打印,再递增 */ for (i = 0; i < NLOOP; i++) { pthread_mutex_lock(&counter_mutex); val = counter; printf("%d: %d/n", pthread_self(), val + 1); counter = val + 1; pthread_mutex_unlock(&counter_mutex); } return(NULL); } 复制代码
使用互斥锁上锁会带来额外的开销,但并不会太大。
互斥锁适合于防止同事访问某个共享变量,但我们需要另外某种在等待期间让我们进入睡眠的方式。条件变量(condition variable)结合互斥锁能够提供这样的功能。互斥锁提供互斥机制,条件变量提供信号机制。
按照Pthread,条件变量是类型为 pthread_cond_t
的变量。以下两个函数用来使用条件变量:
int pthread_cond_wait(pthread_cond_t *cptr, pthread_mutex_t *mptr); int pthread_cond_signal(pthread_cond_t *cptr); 复制代码
以下是一个使用条件变量的例子:
#include <stdio.h> #include <pthread.h> #define NLOOP 5000 int counter; /* 由线程进行递增操作 */ pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t counter_cond = PTHREAD_COND_INITIALIZER; void *doit(void *); int main(int argc, char **argv) { pthread_t tidA, tidB; pthread_create(&tidA, NULL, &doit, NULL); pthread_create(&tidB, NULL, &doit, NULL); /* 主线程循环等待操作完成 */ pthread_mutex_lock(&counter_mutex); while (counter < NLOOP) { pthread_cond_wait(&counter_cond, &counter_mutex); } printf("main: %d/n", counter); pthread_mutex_unlock(&counter_mutex); exit(0); } void *doit(void *vptr) { int i, val; /* 先打印,再递增 */ for (i = 0; i < NLOOP; i++) { pthread_mutex_lock(&counter_mutex); val = counter; if (val < NLOOP) { counter = val + 1; } pthread_cond_signal(&counter_cond); pthread_mutex_unlock(&counter_mutex); } return(NULL); } 复制代码
主循环阻塞在 pthread_cond_wait
调用中,等待某个即将终止的线程发送发送信号到与 counter
关联的条件变量。主循环只在持有互斥锁期间才检查 counter
变量,如果发现无事可做,那么就调用 pthread_cond_wait
。该函数把调用线程投入睡眠并释放调用线程持有的互斥锁。此外,当 pthread_cond_wait
返回时(其他某个线程发送信号到与 counter
关联的条件变量之后),该线程再次持有该互斥锁。
为什么每个条件变量要关联一个互斥锁呢?因为“条件”通常是线程之间共享的某个变量的值。允许不同线程设置和测试该变量要求有一个与该变量关联的互斥锁。举例来说,如果上面的例子中没有使用互斥锁,那么主循环就是这样:
while (counter < NLOOP) { pthread_cond_wait(&counter_cond, &counter_mutex); } 复制代码
这里存在这样的可能:主线程外最后一个线程在主循环测试 counter < NLOOP
之后但在调用 pthread_cond_wait
之前递增了 counter
。如果发生这样的情况,最后那个“信号”就丢失了,造成主循环永远阻塞在 pthread_cond_wait
调用中,等待永远不再发生的某事再次出现。
同样,要求 pthread_cond_wait
被调用时其所关联的互斥锁必须是上锁的,该函数作为单个原子操作解锁该互斥锁并把调用线程投入睡眠也是出于这个理由。要是该函数不先解锁该互斥锁,到返回时再给它上锁,调用线程就不得不实现解锁事后上锁该互斥锁,测试变量counter的代码将变为:
pthread_mutex_lock(&counter_mutex); while (counter < NLOOP) { pthread_mutex_unlock(&counter_mutex); pthread_cond_wait(&counter_cond, &counter_mutex); pthread_mutex_lock(&counter_mutex); } 复制代码
然而这里也可能存在:主线程外最后一个线程在主线程调用 pthread_mutex_unlock
和 pthread_cond_wait
之间终止并递增了counter的值。
pthread_cond_signal
通常唤醒等在相应条件变量上的单个线程。有时候一个线程知道自己应该唤醒多个线程,这时它可以调用 pthread_cond_broadcast
唤醒等在相应条件变量上的所有线程。
int pthread_cond_broadcast(pthread_cond_t *cptr); int pthread_cond_timedwait(pthread_cond_t *cptr, pthread_mutex_t *mptr, const struct timespec *abstime); 复制代码
pthread_cond_timedwait
允许线程设置一个阻塞时间的限制。 abstime
是一个 timespec
结构,指定该函数必须返回时刻的系统时间,即到时候相应条件变量尚未收到信号的话,就会返回 ETIME
错误。
这里的 abstime
是一个绝对时间(absolute time),而不是一个时间增量(time delta)。这一点不同于 select
和 pselect
。使用绝对时间的优点在于,如果该函数过早返回(可能是因为捕获了某个信号,那么不必改动 timespec
结构就可以再次调用该函数;缺点是首次调用该函数之前不得不调用 gettimeofday
。
创建一个线程通常比调用 fork
派生一个进程快得多。仅仅这一点就能够体现线程在繁重使用的网络服务器上的优势。
同一进程内的所有线程共享全局变量和描述符,从而允许不同线程之间共享信息。然而这种共享却引入了同步问题,我们必须使用Pthread同步原语“互斥锁”和“条件变量”来解决。共享数据的同步几乎是每个线程化程序必不可少的部分。
条件变量必须和互斥锁配合使用,这是规范的一部分。这么规定的原因在于如果不配合互斥锁,条件变量会面临可能的信号丢失的问题。这个信号丢失的问题有个专门的名字,叫做lost wake-up problem。
在java 1.2之后的版本,在java中创建的 Thread
,在linux平台下实际上就是 Pthread
。可以看出java中Thread的各个属性与Pthread比较类似(但是没有detached属性)。在同步方面,java有自己的同步机制( synchronized
关键字),并没有直接使用Pthread中的同步原语。java 1.5之后引入的 java.util.concurrent.locks
中的库函数,则与Pthread同步原语由更多的相似的地方。
另外,java中 Object
的 wait/notify/notifyAll
, Condition
的 await/signal
必须要在同步块中,其道理跟条件变量一样,都是为了避免信号丢失的问题。