Never too late to learn.

0%

Linux/UNIX系统编程手册-线程

Linux/UNIX系统编程手册

[德] Michael Kerrisk

第29章 线程:介绍
第30章 线程:线程同步
第31章 线程:线程安全和线程存储
第32章 线程:线程取消
第33章 线程:更多细节

线程(thread)

与进程类似,线程是允许应用程序并发执行多个任务的一种机制。一个进程可以包含多个进程,同一程序中的所有线程均会独立执行相同程序,且共享同一份全局内存区域,其中包括初始化数据段(initialized data)、未初始化数据段(uninitialized data),以及堆内存段(heap segment)。
但每个进程都配有用来存放局部变量的私有栈。
同一进程中的线程还共享一些其他属性,包括进程ID、打开的文件描述符、信号处置、当前工作目录以及资源限制。

同一进程中的多个线程可以并发执行。在多处理器情况下,多个线程可以同时并行。

多进程实现并发时,例如web服务器的设计,通过fork()来创建子进程,同时为多个客户端提供服务。但是有以下限制:

  • 进程间的信息难以共享。由于除去只读代码段外,父子进程并未共享内存,因此需要采取进程间通讯方式交换信息
  • 通过fork()创建进程的代价相对较高,即使利用写时复制技术,仍然需要复制诸如内存页表(page table)、文件描述符表(file descriptor table)之类的多种进程属性。

线程解决了上述问题:

  • 线程能方便的快速的共享信息。使用共享(全局或堆)变量即可,但要注意多线程数据安全问题。
  • 创建线程比创建进程快得多。在Linux中,是通过系统调用clone()来实现线程的,fork()创建时需要复制的诸多属性和页表,在线程间本来就是共享的,无需复制。

线程数据类型(Pthreads data type)

数据类型 描述
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)
prhread_attr_t 线程的属性对象

多线程程序中,每个线程都有属于自己errno。

Pthreads API 函数均以返回0表示成功,返回正值表示失败,与系统调用和库函数的不同,它们返回0表示成功,-1表示失败,并设置errno以表示错误原因。

创建线程

程序启动时,产生的进程中只有单条线程,称之为初始(initial)或主(main)线程。
函数pthread_create()负责创建一条新线程

1
2
3
4
#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

新线程通过调用带有参数arg的函数start而开始执行。调用pthread_create()的线程会继续执行该调用之后的语句。

终止线程

线程终止的方式如下:

  • 线程start函数执行return语句并返回指定值
  • 线程调用pthread_exit()
  • 调用pthread_cancle()取消线程
  • 任意线程调用了exit(),或者主线程执行了return语句,都会导致进程中的所有线程立即终止。
    pthread_exit()函数将终止调用线程,且其返回值可由另一线程通过调用pthread_join()来获取。
    1
    2
    #include <pthread.h>
    void pthread_exit(void *retval)
    调用该函数相当于在线程的start函数中执行return,不同之处在于,可在线程start函数所调用的任意函数中调用pthread_exit()。
    参数retval指定了线程的返回值。如果主线程调用该函数,而非调用exit()或者执行return语句,那么其他线程将继续运行。

线程ID(Thread ID)

进程内部的每个线程都有一个唯一标识,线程ID。线程ID会返回给pthread_create()的调用者,一个线程可以通过pthread_self()来获取自己的线程ID。

函数pthread_equal()可以检查两个线程的ID是否相同。

1
2
3
#include <pthread.h>
int pthread_equal(pthread_t t1, pthread_t t2);
Returns nonzero value if t1 and t2 are equal, otherwise 0

连接(joining)已终止的线程

函数pthread_join()等待由thread标识的线程终止。这种操作被称为连接(joining)。

1
2
3
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
Returns 0 on success, or a positive error number on error

默认情况下,线程是可连接的(joinable),当线程退出时,其他线程可以通过调用pthread_join()获取其返回状态。

线程的分离(Detaching a thread)

有时并不关心线程的返回状态,只是希望系统在线程终止时能够自动清理并移除。这时,可以调用pthread_detach()并向thread参数传入指定线程的标识符,将该线程标记为处于分离(detached)状态。

1
2
3
#include <pthread.h>
int pthread_detach(pthread_t thread);
Returns 0 on success, or a positive error number on error

一旦线程处于分离状态,就不能再使用pthread_join()来获取其状态,也无法使其重返“可连接”状态。

使用pthread_detach(), 线程可以自行分离:

pthread_detach(pthread_self())

线程: 线程同步

保护对共享变量的访问:互斥量(Protecting Accesses to Shared Variables: Mutexes)

线程的主要优势在于,能够通过全局变量来共享信息。但必须保证多个线程不会同时修改同一个变量,或者某一线程不会读取正由其他线程修改的变量。
临界区(critical section):指访问共享资源且执行原子(atomic)操作的代码片段。

为避免线程更新共享变量时出现的问题,必须使用互斥量(mutex–mutual exclusion)来确保同时只有一个进程可以访问某项共享资源。
互斥量有两种状态:锁定(locked)和未锁定(unlocked), 任何时候,至多只有一个线程可以锁定该互斥量。一旦线程锁定互斥量,则只有该所有者才能给互斥量解锁。

静态分配的互斥量和动态初始化互斥量

互斥量是属于pthread_mutex_t类型的变量,在使用之前必须对其初始化。
对于静态分配的互斥量而言,将静态初始值PTHREAD_MUTEX_INITIIALIZER赋给互斥量:

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER

静态初始值只能对如下互斥量初始化:经由静态分配且携带默认属性。
其他情况,比如:

  • 动态分配于堆中的互斥量
  • 互斥量时在栈中分配的automatic 变量
  • 初始化经由静态分配,且不使用默认属性的互斥量
    必须调用pthread_mutex_init()对互斥量进行动态初始化。

加锁和解锁互斥量

初始化之后,互斥量处于未锁定状态。函数pthread_mutex_lock()可以锁定某一互斥量,函数pthread_mutex_unlock()则可以将一个互斥量解锁。

1
2
3
4
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
Both return 0 on success, or a positive error number on error

线程对互斥量的持有时间应该尽可能短,以避免妨碍其他线程的并发执行。

互斥量的死锁(mutex deadlocks)

当一个线程需要同时访问两个或者更多不同的共享资源,而每个资源又都由不同的互斥量管理。当超过一个线程加锁同一组互斥量时,就可能发生死锁(deadlock)。
避免死锁的最简单方法是定义互斥量的层级关系。当多个线程对一组互斥量操作时,总是应该以相同顺序对该组互斥量进行锁定。

通知状态的改变:条件变量(Signaling Changes of State: Condition Variables)

互斥量防止多个线程同时访问同一共享变量。条件变量允许一个线程就某个共享变量(或其他共享资源)的状态变化通知其他进程,并让其他线程等待这一通知。

条件变量允许一个线程休眠(等待)直至接收到另一个线程的通知去执行某些操作。
条件变量总是结合互斥量使用。条件变量就共享变量的状态改变发出通知,而互斥量则提供对该共享变量访问的互斥(mutual exclusion)。

静态分配/动态分配的条件变量

条件变量的数据类型是pthread_count_t,使用前也必须对其初始化。
对于静态分配的条件变量,将其赋值为PTHREAD_COND_INITALIZER即完成初始化操作。

pthread_cont_t cond = PTHREAD_COND_INITIALIZER

条件变量的主要操作是发送和等待通知。发送是指某个变量发生变化,向一个或多个处于等待的进程发送通知,而等待通知的线程在收到通知前一直处于阻塞状态。

函数pthread_cond_signal()和pthread_cond_broadcast()均可针对由参数cond所指定的条件变量发送信号。pthread_cound_wait()函数将阻塞一线程,直至收到条件变量cond的通知。

1
2
3
4
5
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
All return 0 on success, or a positive error number on error

类似于互斥量,动态分配条件变量,需要使用pthread_cond_init()函数,使用情况也相似:

  • 自动或动态分配的条件变量
  • 初始化经由静态分配,未采用默认属性的变量

线程:线程安全(thread safety and pre-thread storage)

若函数可同时供多个线程安全调用,则称之为线程安全函数;反之,如果函数不是线程安全的,则不能并发调用。

实现线程安全有多种方式,如上所述的

  • 将函数与互斥量关联使用
  • 将共享变量与互斥量关联起来
    而最有效的方式是使其可重入(make it reentrant)。

可重入和不可重入(Reentrant and nonreentrant functions):
如果同一个进程的多条线程可以同时安全地调用某一函数,那么该函数就是可重入的。否则是不可重入的。
例如: 更新全局变量或者静态数据结构的函数可能是不可重入的(只用到本地变量的函数肯定是可重入的)。

可重入函数能不使用互斥量实现线程安全,要诀是避免对全局和静态变量的使用。需要返回给调用者的任何信息,抑或是需要在对函数的历次调用间加以维护的信息,都存储于由调用者分配的缓冲区内。

线程特有数据(Thread-Specific Data ???)

实现函数的可重入,需要修改函数的接口。而使用线程特有数据技术,可以无需修改函数接口而实现已有函数的线程安全。线程特有数据使函数得以为每个调用线程分别维护一份变量副本。

Thread-specific
data is persistent; each thread’s variable continues to exist between the thread’s
invocations of the function. This allows the function to maintain per-thread information
between calls to the function, and allows the function to pass distinct result
buffers (if required) to each calling thread.

线程局部存储(Thread-Local Storage)

类似于线程特有数据,线程局部存储也允许函数分配持久的基于线程的存储。其相比之下的优点是使用简单,创建线程局部变量,只需在全局或者静态变量的声明中包含__thread即可。

static __thread buf[MAX_ERROR_LEN]

线程取消

通常情况下,程序中的多个线程会并发执行,每个线程各司其职,直至其决议退出,随即会调用函数pthread_exit()或者从线程启动函数中返回。有时,需要将一个线程取消(cancel), 即向线程发送退出请求。

取消线程函数

函数pthread_cancel()向thread之地那个的线程发送一个取消请求

1
2
3
#include <pthread.h>
int pthread_cancel(pthread_t thread);
Returns 0 on success, or a positive error number on error

发出取消请求后,函数pthread_cancel()当即返回,不会等待目标进程的退出。

取消状态及类型

函数pthread_setcancelstate()和pthread_setcanceltype()会设置标志,允许线程对取消请求的响应过程加以控制。

1
2
3
4
#include <pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
int pthread_setcanceltype(int type, int *oldtype);
Both return 0 on success, or a positive error number on error

state参数: [PTHREAD_CANCEL_DISABLE, PTHREAD_CANCEL_ENABLE] , 即禁用和启用被取消功能

type参数: [PTHREAD_CANCEL_ASYNCHRONOUS, PTHREAD_CANCEL_DEFERED], 即异步和延迟取消

取消点

当线程的取消状态和类型分别为启用和延迟时,仅当线程抵达某个取消点(cancellation point)时,取消请求才会起作用。SUSv3规定了一系列必须为取消点的函数。其中大部分函数都有能力将线程无限期地阻塞。

函数pthread_testcancel()可以用来作为一个不含取消点线程的取消点来响应取消请求。

清理函数(cleanup handler)

线程可以设置一个或多个清理函数,当线程遭到取消时会自动运行这些函数。

函数pthread_cleanup_push()和pthread_cleanup_pop()分别负责向调用线程的清理函数栈添加和移除清理函数。

更多细节

线程栈

创建线程时,每个线程都有一个属于自己的线程栈,且大小固定。可通过线程属性对象创建线程时,调用相关函数(pthread_attr_setstack()等)来设置线程栈的大小和位置。

线程和信号

UNIX信号模型是基于进程模型设计的,信号于线程之间存在明显的冲突。在设计多线程应用程序时应尽量避免使用信号。
为实现信号与线程间交互,多线程模型提供处理信号的各种有效函数。

线程实现模型

内核调度实体(KSE, Kernel Scheduling Entity):是内核分配CPU以及其他系统资源的单位。

实现线程API有3种不同的模型,差异主要集中在线程如何与KSE相映射:

  • 多对一(M:1): 用户级线程,关乎线程创建、调度、同步均由进程内用户空间的线程库来处理,内核一无所知。优点在于,因为无需切换内核模式,速度很快。缺点是当线程发起系统调用时,所有线程都被阻塞;无法充分利用多处理器。

  • 一对一(1:1): 内核级线程,每一个线程映射一个单独的KSE。内核对每个线程做调度,线程同步操作通过内核调用实现。M:1的弊端消除了,性能主要在于切换内核模式的开销以及线程的调度给内核调度器造成的负担。

  • 多对多(M:N): 两级模型。综合以上两者的优点,但是过于复杂。

Linux POSIX线程的实现

针对Pthreads API, Linux有两种实现,LinuxThread和NPTL,均采用1:1模型。

NPTL—Native POSIX Thread Library(NPTL)是Linux内核中实践POSIX Threads标准的库。

Coffee? ☕