Lec 10 线程
License
本内容版权归上海交通大学并行与分布式系统研究所所有
使用者可以将全部或部分本内容免费用于非商业用途
使用者在使用全部或部分本内容时请注明来源
资料来自上海交通大学并行与分布式系统研究所+材料名字
对于不遵守此声明或者其他违法使用本内容者,将依法保留追究权
本内容的发布采用 Creative Commons Attribution 4.0 License
完整文本
1 为什么需要线程
- 进程的开销较大
- 包括了数据,代码,堆栈等。
- 进程的隔离性过强
- 通过进程间通信(IPC),但是开销太大
- 进程内部无法支持并行。
1.1 简单方法:进程+调度
- 进程数量远远超过CPU的核数目
- 简单分配,每个核都至少分到一个进程
- 调度器分时复用,增加计算资源利用的效率
- 通过调度策略,在进程需要等待的时候切换到其他进程执行。
- 局限: 但一进程无法利用多核资源
- 一个进程同一时刻只能被调度到其中一个核上运行
- 如果一个程序想要同时利用多核怎么办?
- Sol:采用
fork()
创建相似进程。创建的进程与原来的进程行为类似,可以用于其他核心的运行。
1.2 Fork 方法存在局限性
- 进程间隔离过强,数据共享十分困难
- 每个进程具有独立的虚拟地址空间,共享以页为粒度
- 协调困难,需要复杂的通信机制。(pipe)
- 进程管理开销大
- 创建:地址空间的复制
- 切换:页表切换
1.3 如何使得进程跨核心运行
- Pros:无需使用fork创建新的进程
- 降低进程管理的开销
- 同一个地址空间数据共享和同步方便
- 需要什么支持?
- 处理器上下文:不同核心执行状态不同,需要独立处理器的上下文。
1.4 线程:更加轻量级的运行时抽象
-
仅包含运行时状态
- 静态部分通过进程提供
- 包含了执行所需的最小状态
-
一个进程可以包含多个线程
- 每个线程共享同一个地址空间
- 允许进程内并行
2 如何使用线程
-
常用库:POSIX threads(pthreads)
- 包含约60个函数的标准接口
- 实现的功能与进程相关系统调用相似
- 创建:pthread_create
- 回收:pthread_join
- 退出:pthread_exit
- 暂停:pthread_yield
-
注意:一个线程执行系统调用,可能影响该进程的所有线程
- 如exit会使所有线程退出
/* 创建线程,打印"hello world!" */
#include <pthread.h>
#include <stdio.h>
/* 进程执行 */
// 子线程。
void *thread(void *args)
{
// detach 分离线程
pthread_detach(pthread_self());
printf("Hello world!\n");
return;
}
int main(int args, char *argv[])
{
pthread_t tid;
// 创建线程接口,赋予线程id,执行起点为thread函数
// 属性通常为NULL,参数在第二个NULL传入。
// 主线程。
pthread_create(&tid, NULL, thread, NULL);
// 解决线程提前结束的情况:等待对应的tid子线程结束后继续进行。
pthread_join(tid, NULL); // 第二个参数接受返回值。
exit(0);
}
- 有时候没有输出!
- 主线程创建子线程后,两线程独立执行
- 若子线程先于exit执行,则printf顺利输出
- exit会导致主线程和子线程全部终止。有可能会导致没有执行完成
printf
。 - 解决办法:加入join操作。
2.1 基于join的方法存在问题
- 手动调用join回收资源,有可能导致资源溢出。(例如多次while循环创建进程导致报错:
(stuck; errno:11)
- 采用
detach()
操作:在线程函数内第一行增加pthread_detach(pthread_self());
一行来完成分离。分离后的线程不会被其他线程杀死或回收,退出时资源自动回收。 - detach后因为子线程与主线程分离,相当于进入了幕后,因此无法跟踪,就不能再使用join操作了。
- 将join操作改为detach操作,我们发现有时候还是没有输出结果。这是因为main函数返回后有隐式调用的exit操作终止所有线程。因此并没有使得子线程完全独立。
- 我们可以改成:将join改为detach后,在后面加上
pthread_exit(0)
,只退出当前线程。输出为Hello, world!\n(stuck)
#include <pthread.h>
#include <stdio.h>
void *thread(void *vargp) {
printf("Hello, world!\n");
while(1);
return NULL;
}
int main() {
pthread_t tid;
pthread_create(&tid, NULL, thread, NULL);
pthread_detach(tid);
pthread_exit(0); // 只退出当前线程。
}
2.2 小结
- 常用接口:
pthread_create
,pthread_join
,pthread_detach
,pthread_exit
。 - 线程资源默认手动回收
- 可以使用
pthread_join
回收其他子线程 - 可以使用
pthread_detach
+pthread_exit
来自动回收其他线程 - 将exit改为
pthread_exit
来仅退出主线程。
- 可以使用
3 线程
3.1 线程历史
略过,感兴趣自行STFW(search the fu***** fantastic web)。
3.2 多线程的进程
-
一个进程可以拥有多个线程
-
多线程进程可以跨处理器执行。
- 调度基本单元从进程变成了线程。
- 每个线程都有自己的执行状态。
- 切换的单位从进程变成了线程。
-
每个线程都有自己的栈
-
内核中也有为线程准备的内核栈
-
其他区域共享(数据,代码,堆)
3.3 对比进程与线程
-
线程和进程的相似之处:
- 都可以与其他进程/线程并发执行(可能在不同核心上)
- 都可以进行切换
- 引入线程后,调度管理单位由进程变为线程
-
线程和进程的不同之处:
- 同一进程的不同线程共享代码和部分数据
- 不同进程不共享虚拟地址空间
- 线程与进程相比开销较低
- 进程控制(创建和回收)通常比线程更耗时
- Linux的数据:
- 创建和回收进程:~20K cycle
- 创建和回收线程:~10K cycle(或更少)
- 同一进程的不同线程共享代码和部分数据
4 TLS:线程本地存储
观察下面的程序:
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
void *thread(void *vargp) {
void *addr = malloc(0);
printf("peer:errno=%d\n", errno);
return NULL;
}
int main() {
pthread_t tid;
pthread_create(&tid, NULL, thread, NULL);
void *addr = malloc(-1);
pthread_join(tid, NULL);
printf("main:errno=%d\n", errno);
}
我们会发现输出结果可能为两种情况:
# 第一种情况:
peer:errno=0
main:errno=0
# 第二种情况:
peer:errno=0
main:errno=12
前者是由于主线程执行后,再执行子线程导致的。errno为一个全局变量,因此子线程将其修改成了0。而后者先执行了子线程,再继续执行主线程。因此errno的值分别为0和12。这是因为每个线程具有不同的虚拟地址空间,这些不同的虚拟地址空间映射到了相同的真实物理地址。
5 线程的实现
5.1 进程控制块PCB到线程控制块TCB
-
PCB的部分内容转移到TCB
- 每个线程TCB保存自己的处理器上下文,内核栈,退出/执行状态
- PCB维护共享地址空间
- PCB与TCB相互引用
-
每个线程适应独立的内核栈
5.2 进行线程创建
比进程创建步骤少(无需加载可执行文件)。
- TCB相关内容初始化
- 维护进程,线程关系
- 准备运行环境。
Linux中采用clone
(本用于创建进程)实现。
创建进程需要多个特殊标记。
- CLONE_VM: 线程(进程?)共享同一地址空间
- CLONE_THREAD: 新的线程(进程?)与原进程同时属于同一进程。
为什么这里会打上问号?因为Linux内部实际上并没有抽象一个新的模型来描述线程,而是以轻量级进程来将他们和线程相互关联起来。
5.3 进程退出与合并
不需要销毁虚拟地址空间vmspace
5.4 与进程管理接口的关系
- 一个多线程的程序调用fork会出现什么情况?
- 所有线程都被拷贝导致重复读/写同一个文件
- 只拷贝了父进程中调用fork的线程
- 新进程中只出现了一个线程,不会出现反直觉的重复操作
- 其他线程内存状态被拷贝并且不被主动释放
- posix:尽量不要使用fork拷贝多线程
- 希望使用多进程:使用fork
- 希望多线程:
pthread_create
5.5 用户态线程与内核态线程
- 根据线程是否受到内核管理将线程分为两类
- 内核态线程:内核可见,受到内核管理
- 用户态线程:内核不可见,不受内核直接管理
- 内核态线程
- 内核创建,线程相关信息存放在内核中
- 用户态线程(纤程)
- 在应用态创建,线程相关信息主要存放在应用数据中
5.6 线程模型
多对一模型
- 将多个用户态线程映射给单一的内核线程
- pros:管理内核简单
- cons:可扩展性差,无法适应多核机器的发展
- 主流操作系统中被弃用,用于各种用户态线程库中
一对一模型
- 每个用户线程映射单一的内核线程
- 优点:解决了多对一模型中的可扩展问题
- 缺点:数量大,开销大
- 主流OS采用
多对多模型(Scheduler Activation)
-
N个用户态线程映射到M个内核态进程(N>M)
- 优点:解决了可扩展性问题(多对一)和线程过多问题(一对一)
- 缺点:管理复杂
-
虚拟化中广泛应用
5.7 TCB
- 一对一的模型结构TCB氛围两部分
- 内核态和PCB结构相似,进程和线程在linux中使用的是同一种数据结构,线程切换中使用
- 应用态使用线程库定义。例如
pthread
结构体,可以认为是TCB(内核)的扩展