POSIX Thread

2,055 阅读11分钟

概述

在传统的unix模型中,当一个进程需要另一个实体来完成某项任务时,它就fork一个子进程出来处理,比如在一个网络服务器程序中,父进程accept一个连接,然后fork一个子进程,由该子进程处理与连接对端的客户端之间的通信。

尽管这种范式很久以来一直用得很好,但是fork调用却存在一些问题:

  • fork是昂贵的。fork要把父进程的内存映像复制到子进程中,并在子进程中复制所有描述符。当前的实现使用写时复制(COW)的技术,来避免子进程切实需要自己的副本之前把父进程的数据复制到子进程。然而即使有这样的优化措施,fork仍然是昂贵的。
  • fork返回之后,复制进程需要通过进程间通信(IPC)来传递信息。从父进程传递信息到子进程相当容易,因为子进程将从父进程的数据空间和描述符的副本开始运行,然而从子进程往父进程传递消息却比较费力。

线程有助于解决这两个问题。线程有时称为轻量进程(lightweight process),因为线程比进程更轻量。也就是说,线程的创建可能比进程的创建快10~100倍。同一进程内的所有线程共享相同的全局内存。这使得线程之间易于共享信息,然而伴随这种简易性而来的却是同步(synchronization)问题。

同一进程内的所有线程除了共享全局变量外还共享:

  • 进程指令
  • 大多数数据
  • 打开的文件(即描述符)
  • 信号处理函数和信号处置
  • 当前工作目录
  • 用户ID和组ID

不过每个线程有各自的:

  • 线程ID
  • 寄存器集合,包括程序计数器和栈指针
  • errno
  • 信号掩码
  • 优先级

本文中讲述的是POSIX线程,也成为Pthread。POSIX线程作为POSIX.1c标准的一部分在1995年得到标准化,大多数unix版本支持这类线程。我们将看到所有的Pthread函数都以ptread_开头。

基本线程函数

pthread_create

当一个程序由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

注意funcarg的声明,func所指函数作为参数接受一个通用指针(void *),又作为返回值返回一个通用指针(void *)。这时的我们可以把一个指针(它指向我们期望的任何内容)传递给一个线程,又允许线程返回一个指针(它同样指向我们所期望的任何内容)。

通常情况下Ptread函数的返回值成功时为0,出错时为某个非0值。与套接字及大多数系统调用出错时返回-1并置errno为某个正值的做法不同的是,Pthread函数出错时作为函数返回值返回正值错误指示。举个例子,如果pthread_create因在线程数目上超过某个系统限制而不能创建新线程,函数返回值将是EAGAIN。Pthread函数不设置errno

pthread_join

我们可以通过调用pthread_join等待一个给定线程终止。对比线程和unix进程,pthread_create类似于forkpthread_join类似于waitpid

pthread_t pthread_join(pthread_t *tid, void ** status);

我们必须制定要等待的线程的tid。不幸的是,Pthread没有办法等待任意一个线程(类似在waitpid中指定参数为-1)。如果status指针非空,来自所等待的线程的返回值(一个指向某个对象的指针)将存入status所指向的位置。

pthread_self

每个线程都有一个在进程内标识自身的ID。线程ID由pthread_create返回,而我们可以在pthread_join中使用它。每个线程可以通过ptread_self获取自身的线程ID。

pthread_t pthread_self(void);

对比unix线程,pthread_self类似于getpid

pthread_detach

一个线程或者是可汇合的(joinable,默认值),或者是脱离的(detached)。当一个可汇合的线程终止时,它的线程ID和退出状态将留存到另一个线程对它调用pthread_join。脱离的线程却像守护进程,当它们终止时,所有相关资源都将被释放,我们不能等待它们终止。

pthread_detach函数把指定的线程变为脱离状态。

pthread_t pthread_detach(pthread_t tid);

pthread_exit

让一个线程终止的方法之一是调用pthread_exit

void pthread_exit(void *status);

如果该线程未曾脱离,它的线程ID和退出状态将一直留存到调用进程内某个其他线程对它调用pthread_join

指针status不能指向局部于调用线程的对象,因为线程终止时,这样的对象也会消失。

让一个线程终止的另外两个方法是:

  1. 启动线程的函数(即pthread_create的第三个参数)可以返回。该函数的返回值就是相应线程的终止状态。
  2. 如果进程的main函数返回或者任何线程调用了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_unlockpthread_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)。这一点不同于selectpselect。使用绝对时间的优点在于,如果该函数过早返回(可能是因为捕获了某个信号),那么不必改动timespec结构就可以再次调用该函数;缺点是首次调用该函数之前不得不调用gettimeofday

总结

创建一个线程通常比调用fork派生一个进程快得多。仅仅这一点就能够体现线程在繁重使用的网络服务器上的优势。

同一进程内的所有线程共享全局变量和描述符,从而允许不同线程之间共享信息。然而这种共享却引入了同步问题,我们必须使用Pthread同步原语“互斥锁”和“条件变量”来解决。共享数据的同步几乎是每个线程化程序必不可少的部分。

条件变量必须和互斥锁配合使用,这是规范的一部分。这么规定的原因在于如果不配合互斥锁,条件变量会面临可能的信号丢失的问题。这个信号丢失的问题有个专门的名字,叫做lost wake-up problem

关联到java

在java 1.2之后的版本,在java中创建的Thread,在linux平台下实际上就是Pthread。可以看出java中Thread的各个属性与Pthread比较类似(但是没有detached属性)。在同步方面,java有自己的同步机制(synchronized关键字),并没有直接使用Pthread中的同步原语。java 1.5之后引入的java.util.concurrent.locks中的库函数,则与Pthread同步原语有更多的相似的地方。

另外,java中Objectwait/notify/notifyAllConditionawait/signal必须要在同步块中,其道理跟条件变量一样,都是为了避免信号丢失的问题。