OS

线程

Operating System Notes

Posted by AH on December 26, 2019

第4章 线程

4.1 概述

一个线程包括:线程ID、PC、Reg组、栈

并与其他线程共享代码、数据和文件

如果进程有多个线程,则可以同时做多个任务

4.1.1 动机

  • 一个应用程序可能需要执行多个相似任务
  • 创建进程很耗费时间和资源
  • 线程可以简化代码,提高效率

内核普遍是多线程的

4.1.2 优点

多线程编程的4个优点:

  1. 响应度高:部分阻塞程序仍然能继续执行
  2. 资源共享:线程默认共享所属进程的内存和资源
  3. 经济(就是便宜的意思):创建进程更耗资源
  4. 多处理器体系结构的应用:加强了并发功能

4.2 多核编程

原先:单CPU系统->多CPU系统

后来:单核->多核(一个处理器芯片上集成多个核)

并发(concurrency)与并行(parallelism)

  • 并发是支持多个任务推进,单CPU单核也可以通过分时实现并发
  • 并行是支持多个任务同时进行

4.2.1 并行编程遇到的挑战

在多核系统上编程遇到的五个常见挑战:

  1. 划分任务:理想情况下并行的任务相互独立,可以在独立的核上运行
  2. 平衡:有些工作不值得开一个单独的执行核
  3. 数据分割:应用要被分割成独立的任务,任务获取和所需的数据也要被分配在独立的核上运行
  4. 数据依赖:若一个任务需要使用另一个任务的数据,则它们在修改数据后必须同步
  5. 测试和调试:多核程序的测试和调试比单核难

4.2.2 并行的类型

  1. 数据并行:将相同数据的子集分配给多个计算和,并在每个核上运行同样的计算步骤

    e.g. 对数组进行分治法求和

  2. 任务并行:将任务分配给多个计算核,每个核运行自己的操作,不同的核也可以操作同样的数据

    e.g. 对数组分别进行求和和求积

4.3 多线程模型

4.3.1 多对一模型

优点:

进程管理在用户空间内完成,高效

缺点:

  • 若有一个线程执行系统调用,整个进程都将阻塞
  • 同一时刻只有一个线程可以接触内核,在多核系统上多线程将无法并行

由于不能利用好多处理器核,多对一现在用的很少了

4.3.2 一对一模型

(和多对一模型比起来,)并发行好一点,在一个线程执行系统调用时,允许其他线程运行

缺点:

创建一个用户线程就要创建一个对应的内核线程,大量的内核线程将影响系统的性能

实例:

Linus和Windows系列都应用了一对一模型

4.3.3 多对多模型

  • 既可以创建任意多用户线程(像多对一一样),且可以在多处理器上并行

  • 还可以在一个线程执行系统调用时不阻塞其他线程(像一对一)

变体:两级模型(two-level model),在多对多基础上允许用户线程绑定到内核线程上

缺点:很难应用

4.4 线程库

线程库的两种应用方式:

  1. 用户级别:库的所有代码和数据结构只在用户空间内,没有系统调用
  2. 内核级别:库由OS直接支持,库的代码和数据结构存在内核空间,调用该库的API会引发系统调用

POSIX Pthreads既可以用户级别也可以内核级别;Windows线程库是内核级别的;Java 线程API运行在host OS的JVM上,跟着host OS走,即在Windows上调用Windows API,在Linux和macOS上调用Pthreads

异步线程和同步线程:

  • 异步:父线程创建子线程后,父线程继续执行,父子并发、独立执行,所以数据共享很少(常用于设计响应式UI)
  • 同步:父线程创建子线程后,必须等待所有子线程结束才能继续执行(但子线程之间并发执行),每当一个子线程结束,它将加入父线程。同步线程有较多数据共享(e.g.父线程需要结合所有子线程的结果)

以下所有实例都是同步进程。

4.1.4 Pthreads

  • 参考了POSIX标准,Pthreads是一种规范而不是实际应用。

  • 类UNIX系统(包括Linux和macOS)都应用了Pthreads,Windows有第三方应用支持Pthreads

pthread_create()、pthread_join()

4.4.2 Windows Threads

CreateThread()、WaitForMultipleObjects()

4.4.3 Java Threads

每个Java程序由至少一个单独的控制线程组成

4.5 隐式线程化实现(Implicit Threading)

写并行的程序太难了,通过编译器和运行时库创建和管理线程的技术叫做隐式线程化实现。

五种方法:

  1. 线程池
  2. Fork-Join
  3. OpenMP
  4. GCD
  5. Intel TBB

4.5.1 线程池

多线程服务器还是有潜在缺点:

  1. 创建线程需要时间
  2. 线程结束任务后就被丢弃,不限制线程数量会耗尽系统资源

解决方案:线程池

  1. 创建一堆线程,然后放在池子里
  2. 服务器收到请求时,不创建新线程,而是向线程池提交请求,然后继续等待用户请求
  3. 如果池子里没有可用线程,则任务排队
  4. 一旦有线程完成工作就回到池子里等着安排新工作

好处:

  1. 用既有线程而不是创建新进程比较快
  2. 线程池限制了线程的数量,防止系统里有过多线程
  3. 分离了创建线程和执行任务的机制,运行任务时我们就可以用不同的策略,比如延时或者周期性执行

高级的线程库可以动态调整池的大小

4.5.2 Fork Join

父线程创建(fork)一个或多个子线程,然后等待它们并回(join)

与显式线程实现中的同步不同,隐式实现中线程不是在fork阶段直接创建的,而是被指派了并行任务

4.5.3 OpenMP

OpenMP是一组编译器指令,也是为C、C++或FORTRAN程序编写的API,它为共享内存环境中的并行编程提供支持。

OpenMP将并行区域标识为可以并行运行的代码块。程序员将编译器指令插入并行区域的代码中,这些指令指示OpenMP运行时库并行执行该区域。

OpenMP允许开发者选择并行程度。例如,手动设置线程数量、标识数据可以共享还是对某个线程私有。

在一些开源和商用的编译器上都有OpenMP,Linux、Windows、macOS都有

4.5.4 GCD: Grand Central Dispatch

GCD是苹果为macOS和iOS开发的技术,是运行时库、API、语言扩展的组合体。

GCD管理了线程的大多数细节。

块和闭包:

  • GCD在块(block)内识别C、C++、Objective-C语言。块被包含在^{}里。

  • GCD在闭包(closure)内识别Swift。闭包被包含在{}里。

GCD把要执行的任务放在调度队列中。当一个任务出队时,GCD会将该任务分配给(它管理的)线程池中的可用线程。GCD可以识别两种调度队列:串行(serial)和并发(concurrency)

  • 串行队列中的任务是按FIFO顺序出队的。每个进程都有自己的串行队列(也叫主要队列main queue),开发者也可以为进程创建额外的串行队列。

  • 并行队列中的任务也是按FIFO顺序出队的,但几个任务可以同时出队。

4.5.5 Intel Thread Building Blocks (TBB)

TBB是为设计并行C++程序的模板库。

4.6 Threading Issues

4.6.1 fork()和exec()系统调用

  • fork()到底复制了所有线程还是调用fork()的那个线程?

    有些UNIX系统提供了两种

  • exec()会像之前一样,替换整个进程(也就是所有线程)

4.6.2 信号处理(Signal Handling)

在UNIX系统中信号(signal)用来告知一个进程一个特定事件有没有发生。

信号接收分为同步和异步,但两种信号都遵循以下模式:

  1. 信号是由某个特定事件的出现生成的
  2. 信号被送给一个进程
  3. 一旦送到,信号必须被处理(处理方式有两种:默认default handler、用户定义user-defined handler)

同步信号:包括非法内存访问和0除法。它会被送到产生信号的那个进程中去(因而称为同步)

异步信号:由运行进程外部的事件产生,例如keystrokes和计时器结束。一般来说异步信号会被送到另一个进程中。

每个信号都有默认的信号处理器(signal handler),也可以被用户定义的信号处理器重载(override)

单线程程序的信号处理很直接:信号总是被送给进程

多线程程序的信号传送更复杂,有以下几种选项:

  1. 把信号传给它应用的线程
  2. 把信号传给进程中所有线程
  3. 把信号传给进程中特定线程
  4. 指派一个特定线程专门接收进程的所有信号

4.6.3 线程取消(Thread Cancellation)

i.e. 在线程结束任务之前取消它

要取消的线程叫目标线程

两种方式:

  1. 异步取消(asynchronous cancellation):立即马上结束目标进程
  2. 推迟取消(deferred cancellation):允许目标线程周期性检查它要不要被取消

默认方式是推迟取消

如果一个进程将取消状态设为disabled,则即便收到取消信号,也要等它开启取消状态才能取消。

在Linux中,线程取消是通过信号处理的

4.6.4 线程本地存储(TLS: Thread-Local Storage)

TLS允许每个线程有自己数据的拷贝

当无法控制线程创建过程时很有用(e.g. 线程池)

和局部变量(local variable)的区别:

  1. 局部变量只在单个函数调用的时候可见

  2. TLS在所有函数调用时都可见

有点像static数据,但TLS对每个线程都是独一无二的

4.6.5 调度器激活(Scheduler Activations)

多对多和两层模型里,需要交流来保持合适数量的被分配给应用的内核线程

很多系统在用户和内核线程中间应用轻量级进程(LWP: Light Weighted Process)

  • 对用户线程库来说,LWP就像一个虚拟处理器,应用可以在上面调度一个用户线程
  • 每一个LWP都和一个内核线程相关联,OS真正在物理处理器上调度的是内核线程

内核线程阻塞的时候,LWP也阻塞,于是和它关联的用户线程也阻塞。

调度器激活提供上调:一种从内核到线程库中上调处理器交流的机制。

这个交流使应用能保持合适数量的内核线程。

4.7 OS实例