多线程编程

1. 概述

线程是CPU使用的基本单元。它包括线程ID、程序计数器、寄存器组和堆栈。

一个繁忙的web服务器,可能有多个(可能上千个)客户端并发访问它。怎么解决?

  • 多进程。服务器收到请求后,创建另一个进程以便处理请求。缺点:耗费时间和资源。
  • 多线程。创建新线程以处理请求。

在远程过程调用中,RPC服务器通常也是多线程的。当服务器收到消息,它使用一个单独线程处理消息。这允许服务器处理多个并发请求。

优点:

  • 响应性:增加响应效率。
  • 资源共享:进程只能通过共享内存,消息传递来共享资源。这些需要程序员显示的安排。不过,线程默认共享它所属的进程的资源和内存。
  • 经济:创建进程开销昂贵。对于Solaris,创建进程要比线程慢30倍,切换进程要比线程慢5倍。
  • 可伸缩性:对于多处理器体系结构,多线程可以在多核并行运行。

2. 多核编程

无论多个计算核在在多个CPU芯片上还是单个CPU芯片上,我们都称之为多核系统。

对于多核系统,多个线程能够并行运行。

并发 vs 并行?

  • 并发:多个任务交替使用CPU。

  • 并行:并行系统可以同时执行多个任务。

并行分为数据并行和任务并行,通常应用程序混合使用两种策略。

3. 多线程模型

有两种方法来提供线程支持:用户线程和内核线程。

用户线程位于内核线程之上,它的管理无需内核支持。内核线程由操作系统支持和管理。

用户线程和内核线程的三种关系:

3.1 多对一

多个用户线程对应一个内核线程。

优点:线程管理由用户空间的线程库完成,效率更高。

缺点:第一,如果一个线程执行阻塞系统调用,整个进程将会阻塞。第二,因为任一时间只有一个线程可以访问内核,所以多个线程不能并行运行在多处理器系统上。

现在几乎没有系统使用这个模型。

3.2 一对一

每个用户线程映射到一个内核线程。

优点:第一,更好的并发性:当一个线程执行阻塞系统调用时,允许另一个线程继续执行。第二,允许多个线程并行运行在多处理器系统。

缺点:创建一个用户线程就要创建一个内核线程,开销大,限制了系统支持的线程数量

3.3 多对多

多个用户线程多路复用到多个内核线程。

优点:第一,用户可以创建任意多用户线程,并且内核线程能在多个处理器系统上并发执行。第二,当一个线程执行阻塞系统调用时,内核可以调度另一个线程来执行。

4. 线程库

目前使用的三种主要线程库是:POSIX Pthreads、Windows,Java。

Pthreads作为POSIX标准的扩展,可以提供用户级或系统级的库。对于POSIX线程,全局声明的任何数据,可为统一进程的所有线程共享。因为Java没有全局数据的概念,所以全局数据需要显式安排。

创建线程的两个策略:

  • 异步线程:一旦父线程创建一个子线程后,父线程就回复自身的执行,这样父线程和子线程会并发执行。
  • 同步线程:创建子线程后,父线程在恢复执行之前,等待所有子线程的终止。

下面的例子,都使用同步线程。

下面构造了一个多线程程序,通过一个独立线程来计算非负整数的累加和。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>


int sum; // 这个数据在多个线程间共享
void *runner(void *parma); // 多线程调用这个函数

int main(int argc, char *argv[]) {
    pthread_t tid; // 线程标识符
    pthread_attr_t attr; // 线程的属性

    if (argc != 2) {
        fprintf(stderr, "usage: a.out <integer value>\n");
        return -1;
    }
    if (atoi(argv[1]) < 0) {
        fprintf(stderr, "%d must be >=0\n", atoi(argv[1]));
        return -1;
    }

    // 初始化属性
    pthread_attr_init(&attr);

    // 创建线程
    pthread_create(&tid, &attr, runner, argv[1]);

    // 等待线程结束
    pthread_join(tid, NULL);

    printf("sum=%d\n", sum);
}

void *runner(void *param) {
    int i, upper = atoi(param);
    sum = 0;

    for (i = 1; i <= upper; i++)
        sum += i;
    pthread_exit(0);
}

5. 隐式多线程

有一种方法是将多线程的创建和管理交给编译器和运行时来完成,这种策略叫做隐式线程(implicit threading)。

6. 多线程问题

6.1 系统调用fork

如果程序内的某个线程调用fork(),那么新进程有两种形式,一种是复制所有线程,另一种仅仅复制调用fork的线程。如果分叉后立即调用exec,那么没必要复制所有线程。反之,会复制所有线程。

6.2 信号处理

信号(signal)用于通知进程某个特定时间已经发生。

特点:

  • 信号是由特定时间的发生而产生的。

  • 信号被传递给某个进程。

  • 信号一旦收到就应处理

两种模式:

  • 同步信号:发送到执行操作导致这个信号的同一个进程。访问非法内存或除0。
  • 异步信号:由运行程序以外的事件产生的,该进程就异步接收这个信号。如使用control-C终止进程。

如何处理:

  • 单线程程序:传递给产生信号的线程
  • 多线程程序:不一定了。

6.3 线程撤销

线程完成之前终止线程。

类型:

  • 异步撤销:一个线程立即终止目标线程。
  • 延迟撤销 :目标线程不断检查它是否应该终止。

6.4 线程本地存储

每个线程需要它自己的某些数据,成为线程本地存储(Thread-Local Storage, TLS)

类似flask里的threadlocal

6.5 调度程序激活

尽管内核级线程在一些关键点上优于用户级线程,但无可争议的是内核级线程的速度慢。为了保持内核线程优良特性的前提下改进其速度,研究人员设计了调度程序激活(scheduler activation)机制。

目的:模拟内核线程的功能,但是为线程提供通常在用户空间才能实现的更好的性能和灵活性。

参考:https://book.51cto.com/art/201707/546289.htm

7. 例子

在Linux中,并不区分进程和线程。线程通常成为“轻量级进程”。

参考:

  1. https://mp.weixin.qq.com/s/wTicQwTu8Ta8gLv2fZ3rCA
  2. https://sites.cs.ucsb.edu/~tyang/class/240a17/slides/pthreads.pdf