1. 线程概述

与进程(process)类似,线程(thread)是允许应用程序并发执行多个任务的一种机制。

如上图所示,一个进程内可以包含多个线程。同一程序中的所有线程均会独立执行相同程序(即有共同的代码段),且共享同一份全局内存区域,其中包括初始化数据段(initialized data)未初始化数据段(uninitialized data),以及堆内存段(heap segment)。(传统意义上的UNIX 进程只是多线程程序的一个特例,该进程只包含一个线程。)。

除此之外,线程间还共享一些其他的属性:

  • 进程ID和父进程ID
  • 打开的文件描述符
  • 信号处理器程序和信号的处理动作
  • 当前的工作目录

同样线程间还有些独享的属性:

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

同一进程中的多个线程可以并发执行。在多处理器环境下,多个线程可以同时并行。如果一线程因等待I/O 操作而遭阻塞,那么其他线程依然可以继续运行。

传统UNIX通过创建多个进程来实现并行任务。以网络服务器的设计为例,服务器进程(父进程)在接受客户端的连接后,会调用fork()来创建一个单独的子进程,以处理与客户端的通信。采用这种设计,服务器就能同时为多个客户端提供服务。虽然这种方法在很多情境下都屡试不爽,但对于某些应用来说也确实存在如下一些限制。

  • 进程间的信息难以共享。由于除去只读代码段外,父子进程并未共享内存,因此必须采用一些进程间通信(inter-process communication,简称IPC)方式,在进程间进行信息交换。
  • 调用fork()来创建进程的代价相对较高。即便利用写时复制(copy-on-write)技术,仍然需要复制诸如内存页表(page table)和文件描述符表(filedescriptor table)之类的多种进程属性,这意味着fork()调用在时间上的开销依然不菲。

线程解决了上述两个问题。

  • 线程之间能够方便、快速地共享信息。只需将数据复制到共享(全局或堆)变量中即可。不过,要避免出现多个线程试图同时修改同一份信息的情况,这就需要同步技术
  • 创建线程比创建进程通常要快10 倍甚至更多。(在 Linux 中,是通过系统调用 clone()来实现线程的)线程的创建之所以较快,是因为调用 fork()创建子进程时所需复制的诸多属性,在线程间本来就是共享的。特别是,既无需采用写时复制来复制内存页,也无需复制页表。

2. Pthreads API

20世纪80年代末、90年代初,线程有许多不同的实现,所以也一直没有一个统一的线程接口。1995年著名的POSIX标准终于对线程接口进行了标准化,也即Pthreads API。下面简要介绍Pthreads API中用到的一些概念。

线程数据类型

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 一次性初始化控制上下文(control context)
pthread_attr_t 线程的属性对象

标准并未规定如何实现这些数据类型,可移植的程序应将其视为不透明数据。亦即,程序应避免对此类数据类型变量的结构或内容产生任何依赖。尤其是,不能使用C语言的比较操作符(==)去比较这些类型的变量。

errno

在传统UNIX API 中,errno是一全局整型变量。然而,这无法满足多线程程序的需要。如果线程调用的函数通过全局errno返回错误时,会与其他发起函数调用并检查errno的线程混淆在一起。因此,在多线程程序中,每个线程都有属于自己的errno。在Linux 中,线程特有errno的实现方式与大多数UNIX 实现相类似:
errno定义为一个宏,可展开为函数调用,该函数返回每个线程各自的errno

Pthreads API 返回值

从系统调用和库函数中返回状态,传统的做法是:返回0表示成功,返回-1表示失败,并设置errno以标识错误原因。Pthreads API 则反其道而行之。所有Pthreads函数均以返回0表示成功,返回一正值表示失败。这一失败时的返回值,与传统UNIX系统调用置于errno中的值含义相同。

编译Pthreads程序

在Linux平台上,在编译调用了Pthreads API 的程序时,需要设置gcc -pthread的编译选项。使用该选项的效果如下:

  • 定义_REENTRANT预处理宏。这会公开对少数可重入(reentrant)函数的声明。
  • 程序会与库libpthread进行链接(等价于-lpthread)。

3. 创建线程

启动程序时,产生的进程只有单条线程,称之为初始(initial)或主(main)线程。本节将讨论其他线程的创建过程。

函数pthread_create()负责创建一条新线程。

#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指定(相当于start(arg))。调用pthread_create()的线程会继续执行该调用之后的语句。

参数argvoid *类型,这意味着你可以将指向任意类型的指针当作这个函数的参数,甚至在某些简单的情况下,可以用该指针变量直接传递int数据(即(void *)int)。

start()的返回值类型为void *,对其使用方式与arg相同。这个返回值可以通过后面介绍的pthread_join()函数获得。

threadpthread_t *类型的输出型参数,在start()返回之前,会在此处保存这个新线程的线程ID。

标准明确指出,具体的实现无需在新线程开始运行之前就将thread参数的值赋值完毕,这说明有可能pthread_create()在返回之前子进程就已经开始运行了。

参数attr是指向pthread_attr_t类型的指针,该对象指定了新线程的各种属性。这些属性初学可能并不常用,故不在此做更多介绍。如果将attr设置为NULL,那么创建新线程会使用默认的属性。

调用pthread_create()后,应用程序无从确定系统接着会调度哪一个线程来使用CPU 资源(在多处理器系统中,多个线程可能会在不同CPU上同时执行)。

4. 终止线程

可以用如下的几种方式终止线程的运行:

  • 线程运行的start()函数执行return语句返回。
  • 线程调用pthread_exit()
  • 调用pthread_cancel()取消线程
  • 任意线程调用exit(),或者主线程在main函数中执行了return语句,都会导致进程中的所有线程立即终止。

pthread_exit()函数将终止调用进程。

#include <pthread.h>
void pthread_exit(void *retval)

调用pthread_exit()相当于在线程的start()函数中执行return语句,不同的是,线程可以在其调用的任何函数中调用pthread_exit()

参数retval指定了线程的返回值,这个返回值可以通过pthread_join()函数获得。retval所在的空间不应分配在线程栈上,因为随着线程的终止,原来的线程栈所在的位置会被其他线程占用。

如果主线程调用pthread_exit(),而非调用exit()或是执行return语句,那么其他线程将继续运行。

5. 线程ID

进程内部的每个线程都有一个唯一的线程ID(Linux实现中,这个线程ID甚至是进程间唯一的)。线程ID会返回给pthread_create()的调用者,而pthread_self()可以获得自己的线程ID。

#include <pthread.h>
pthread_t pthread_self();

Linux中将pthread_t定义为无符号长整型(unsigned long),但注重可移植性的程序必须把它当作不透明的数据类型来看待。

POSIX线程ID与Linux专有的系统调用gettid()所返回的线程ID 并不相同。POSIX线程ID由线程库实现来负责分配和维护。gettid()返回的线程ID 是一个由内核(Kernel)分配的数字,类似于进程ID(process ID)

6. 连接(joining)已终止的线程

函数pthread_join()等待某个线程终止,这个操作类似于waitpid()。如果这个线程已经终止则立刻返回。这个操作被称为连接(joining)。

#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);

参数thread标志了要等待的进程的ID。

参数retval为输出型参数,若其不为空,则会存储等待退出的线程的返回值,因为线程的返回值为void *类型,所以这里的retval输出型参数为void **类型。

若线程并未分离(下一节介绍),则必须由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())。

无法连接任意线程是刻意设计出来的,由于线程间没有进程间的层次关系,所以连接进程内的任意线程是很不稳定的,例如有可能连接到库函数创建的线程,这会导致很多莫名其妙的BUG。

小练习

下面通过一个小程序实践一下上面的三个函数。

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

void *thread_start(void *arg) {
    char *str = (char *)arg;
    printf("arg: [%s]\n", str);

    pthread_exit((void *)strlen(str));
}

int main() {
    pthread_t t;
    void *res;

    int ret = pthread_create(&t, NULL, &thread_start, "Hello World!");
    if(ret != 0) {
        printf("pthread_create() call failed!\n");
        exit(-1);
    }

    ret = pthread_join(t, &res);
    if(ret != 0) {
        printf("pthread_join() call failed!\n");
        exit(-1);
    }

    printf("length: %ld\n", (long)res);

    return 0;
}

7. 线程的分离

默认情况下,线程是可连接的(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_detach(pthread_self());

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

Last modification:December 1st, 2019 at 11:06 am