第4章 线程
4.1 概述
一个线程包括:线程ID、PC、Reg组、栈
并与其他线程共享代码、数据和文件
如果进程有多个线程,则可以同时做多个任务
4.1.1 动机
- 一个应用程序可能需要执行多个相似任务
- 创建进程很耗费时间和资源
- 线程可以简化代码,提高效率
内核普遍是多线程的
4.1.2 优点
多线程编程的4个优点:
- 响应度高:部分阻塞程序仍然能继续执行
- 资源共享:线程默认共享所属进程的内存和资源
- 经济(就是便宜的意思):创建进程更耗资源
- 多处理器体系结构的应用:加强了并发功能
4.2 多核编程
原先:单CPU系统->多CPU系统
后来:单核->多核(一个处理器芯片上集成多个核)
并发(concurrency)与并行(parallelism):
- 并发是支持多个任务推进,单CPU单核也可以通过分时实现并发
- 并行是支持多个任务同时进行
4.2.1 并行编程遇到的挑战
在多核系统上编程遇到的五个常见挑战:
- 划分任务:理想情况下并行的任务相互独立,可以在独立的核上运行
- 平衡:有些工作不值得开一个单独的执行核
- 数据分割:应用要被分割成独立的任务,任务获取和所需的数据也要被分配在独立的核上运行
- 数据依赖:若一个任务需要使用另一个任务的数据,则它们在修改数据后必须同步
- 测试和调试:多核程序的测试和调试比单核难
4.2.2 并行的类型
-
数据并行:将相同数据的子集分配给多个计算和,并在每个核上运行同样的计算步骤
e.g. 对数组进行分治法求和
-
任务并行:将任务分配给多个计算核,每个核运行自己的操作,不同的核也可以操作同样的数据
e.g. 对数组分别进行求和和求积
4.3 多线程模型
4.3.1 多对一模型
优点:
进程管理在用户空间内完成,高效
缺点:
- 若有一个线程执行系统调用,整个进程都将阻塞
- 同一时刻只有一个线程可以接触内核,在多核系统上多线程将无法并行
由于不能利用好多处理器核,多对一现在用的很少了
4.3.2 一对一模型
(和多对一模型比起来,)并发行好一点,在一个线程执行系统调用时,允许其他线程运行
缺点:
创建一个用户线程就要创建一个对应的内核线程,大量的内核线程将影响系统的性能
实例:
Linus和Windows系列都应用了一对一模型
4.3.3 多对多模型
-
既可以创建任意多用户线程(像多对一一样),且可以在多处理器上并行
-
还可以在一个线程执行系统调用时不阻塞其他线程(像一对一)
变体:两级模型(two-level model),在多对多基础上允许用户线程绑定到内核线程上
缺点:很难应用
4.4 线程库
线程库的两种应用方式:
- 用户级别:库的所有代码和数据结构只在用户空间内,没有系统调用
- 内核级别:库由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)
写并行的程序太难了,通过编译器和运行时库创建和管理线程的技术叫做隐式线程化实现。
五种方法:
- 线程池
- Fork-Join
- OpenMP
- GCD
- Intel TBB
4.5.1 线程池
多线程服务器还是有潜在缺点:
- 创建线程需要时间
- 线程结束任务后就被丢弃,不限制线程数量会耗尽系统资源
解决方案:线程池
- 创建一堆线程,然后放在池子里
- 服务器收到请求时,不创建新线程,而是向线程池提交请求,然后继续等待用户请求
- 如果池子里没有可用线程,则任务排队
- 一旦有线程完成工作就回到池子里等着安排新工作
好处:
- 用既有线程而不是创建新进程比较快
- 线程池限制了线程的数量,防止系统里有过多线程
- 分离了创建线程和执行任务的机制,运行任务时我们就可以用不同的策略,比如延时或者周期性执行
高级的线程库可以动态调整池的大小
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)用来告知一个进程一个特定事件有没有发生。
信号接收分为同步和异步,但两种信号都遵循以下模式:
- 信号是由某个特定事件的出现生成的
- 信号被送给一个进程
- 一旦送到,信号必须被处理(处理方式有两种:默认default handler、用户定义user-defined handler)
同步信号:包括非法内存访问和0除法。它会被送到产生信号的那个进程中去(因而称为同步)
异步信号:由运行进程外部的事件产生,例如keystrokes和计时器结束。一般来说异步信号会被送到另一个进程中。
每个信号都有默认的信号处理器(signal handler),也可以被用户定义的信号处理器重载(override)
单线程程序的信号处理很直接:信号总是被送给进程
多线程程序的信号传送更复杂,有以下几种选项:
- 把信号传给它应用的线程
- 把信号传给进程中所有线程
- 把信号传给进程中特定线程
- 指派一个特定线程专门接收进程的所有信号
4.6.3 线程取消(Thread Cancellation)
i.e. 在线程结束任务之前取消它
要取消的线程叫目标线程
两种方式:
- 异步取消(asynchronous cancellation):立即马上结束目标进程
- 推迟取消(deferred cancellation):允许目标线程周期性检查它要不要被取消
默认方式是推迟取消
如果一个进程将取消状态设为disabled,则即便收到取消信号,也要等它开启取消状态才能取消。
在Linux中,线程取消是通过信号处理的
4.6.4 线程本地存储(TLS: Thread-Local Storage)
TLS允许每个线程有自己数据的拷贝
当无法控制线程创建过程时很有用(e.g. 线程池)
和局部变量(local variable)的区别:
-
局部变量只在单个函数调用的时候可见
-
TLS在所有函数调用时都可见
有点像static数据,但TLS对每个线程都是独一无二的
4.6.5 调度器激活(Scheduler Activations)
多对多和两层模型里,需要交流来保持合适数量的被分配给应用的内核线程
很多系统在用户和内核线程中间应用轻量级进程(LWP: Light Weighted Process)
- 对用户线程库来说,LWP就像一个虚拟处理器,应用可以在上面调度一个用户线程
- 每一个LWP都和一个内核线程相关联,OS真正在物理处理器上调度的是内核线程
内核线程阻塞的时候,LWP也阻塞,于是和它关联的用户线程也阻塞。
调度器激活提供上调:一种从内核到线程库中上调处理器交流的机制。
这个交流使应用能保持合适数量的内核线程。