线程概念
操作系统中的线程
线程(thread)是进程内部的一个执行序列。可以将线程理解为进程的进一步细分,一个进程至少有一个执行流,这也是之前所讨论的单线程进程。将进程进一步细分的原因有很多,一方面,每个线程都可以看作一个独立的执行流,并且这些执行流是共享进程中的相当一部分资源的,即一个进程中的所有线程都可以天然进行通信,这是单纯的多进程所不具备的;另一方面,线程更加轻量级,创建、回收和调度一个线程的成本比进程低得多,同时,在多CPU的机器中,多线程使真正的程序并发变得可能。
Linux中的线程
须知,任何执行流要执行,都必须要有自己的资源,诸如代码和数据,线程也不例外。线程是进程的细分,线程在进程的地址空间内运行,线程的资源来自进程。透过进程地址空间可以看到进程的大部分资源,将进程资源合理地分配给线程,就形成了线程执行流。可以说,一个进程的所有线程是划分与共享进程地址空间中的资源的,对于单线程的进程来说也不例外,无非是只有一个主线程占有所有的进程资源。当创建一个线程时,操作系统不会给这个线程另外分配资源和时间片;同时,线程本身也是一个独立的调度实体,操作系统不会对进程和线程进行区分,而是将它们统一视为执行流进行调度。所以,线程是操作系统调度的基本单位,进程是操作系统分配资源的基本实体。线程本身是进程的一种资源,可以将一个进程看作所有线程 + 内核数据结构 + 所占有的系统资源
。
线程是基本的调度单位,为了对系统中所有的线程进行管理,操作系统首先要对线程进行抽象描述,然后进一步对线程进行组织。考虑到线程与进程在行为与结构上的相似性,为了方便与轻量化,Linux内核中直接用进程相关的数据结构对线程进行了模拟,内核对线程的描述、组织方式与进程类似(这里须知,线程的出现相对于进程较晚)。这并不意味着Linux中没有线程,Linux具有明确的线程概念,只是复用了进程相关的内核结构对其进行描述和组织罢了。
如上文所说,操作系统无法区分进程和线程,进程和线程对于操作系统来说都属于执行流,操作系统只认执行流,并对它们进行调度。由于Linux对线程实现的特殊性,Linux下的执行流也会被称为轻量级进程。Linux中对进程及其线程的组织关系大致如下:
进程与线程
为了填补之前讨论进程与内存管理时留下的空缺,这里首先对页表结构进行讨论。页表是负责进程虚拟地址空间与物理内存空间进行转换的结构,虚拟地址空间中的虚拟地址通过页表和 MMU 映射到物理内存,实现了进程管理与内存管理的解耦。
在32位机器下,具有 32 位虚拟地址,一共可以组成 232 个虚拟地址,虚拟地址空间大小为 4GB。为了将这些空间全部管理在内,Linux使用分级页表的方法,将32位虚拟地址进行分用,前10位分配给页目录,中间10位分配给页表项,最后12位留作具体定位用。页目录是一级页表,存储了页表项的索引,最多有1024个元素;页表项是二级页表,页表项存储物理内存中页框的地址,最多有1024个元素。通过页目录 + 页表项的映射,可以定位到物理内存中的某个页框,再通过页框地址 + 最后12位构成的[0, 4KB)
偏移量,就可以定位到具体某个物理地址。
当要访问的地址在物理内存中不存在时,会触发缺页中断,cr2
寄存器会保存引发中断的虚拟地址,当将代码/数据加载到内存、填充页表后,CPU会再次访问这个虚拟地址。
对于某个数据类型,计算机只知道这个数据的首地址,而在实际编程中,计算机却能正确的将数据进行存储和取出,这是因为有数据类型的存在。数据类型本质是是一个偏移量,告诉计算机从数据的首地址向后取多少才能拿到这个完整的数据。可以认为,起始地址 + 数据类型 = 起始地址 + 偏移量
。
进程对线程进行资源分配,本质是分配自己的地址空间的范围。一个进程的代码和数据本身具有不同的虚拟地址,用户只需要让不同的线程访问不同的代码/数据,就实现了对线程的资源分配。注意用户只是控制线程访问不同的代码/数据而已,线程本身是都能看到其他线程的资源的。
对进程与线程的效率问题的讨论是不可避免的。相较于进程,线程更加轻量化,这种轻量化主要表现在两个方面:
- 线程占有的资源更少,创建和销毁更加轻量化。
- 线程的切换更加轻量化。线程切换时不需要切换页表、地址空间等结构,并且线程切换时不需要重新加载进程的热数据。
线程的轻量化是线程的优点之一。此外,对于IO密集型的应用,多线程可以重叠IO时间,同时等待不同的IO操作,在等待慢速IO时,其他线程可以同时进行计算操作。
多线程还可能有很多不可忽略的问题。对于一个很少被外部阻塞的计算密集型程序,如果线程数量多于可用的处理器,往往会因为CPU对这些线程的同步和调度工作造成性能损失。多线程程序的健壮性难以得到保证,多线程在调度时间分配上产生偏差,或者共享了不恰当的资源而造成不良影响的可能性很大,线程之间是缺乏保护的。除此之外,编写和调试一个多线程程序往往比单线程较难。
综上,合理利用多线程,对于CPU密集型的程序,可以提高执行效率,对于IO密集型的程序,可以提高响应速度,改善用户体验。
关于进程与线程的另一个话题是关于信号的。信号是进程层面的,当线程收到信号时,本质是进程收到了信号。当线程出现异常时,本质是进程出现了异常,如果没有自定义捕捉,则进程默认退出,所有线程也会退出。进程的pending
表和handler
表是所有线程共享的,当某个线程收到信号时,进程的pending表首先会记录这个信号,如果这个信号没有被阻塞,则handler表中对应的方法被执行。任何一个线程对handlber表的修改,其他线程也都要遵守。
上文一直在说,线程的大部分资源是共享自进程的,进程的所有线程共享进程的地址空间,所以进程的数据段、代码段和堆都是所有线程共享的,用户所定义的函数和全局变量,在所有线程中都是可见的。除此之外,线程还共享以下资源:
- 文件描述符表 这意味着进程的文件资源是线程共享的。
- handler表 进程对信号的处理方式是线程共享的。
- 当前工作目录(cwd)
线程共享进程地址空间,而线程是CPU调度的基本单位,仅仅有这些共享数据并不能保证和体现线程的独立性,每个线程也有一部分自己独立的数据:
- CPU寄存器和线程栈 每个执行流的本质其实是一条调用链,而调用链本质是一个调用栈。线程的CPU寄存器是独立的,每个线程都有自己独立的上下文数据。CPU寄存器和线程栈的独立,表明线程的调度和运行是独立的。
- errno C/C++中的
errno
保存了最近一次系统调用执行的错误码,在不同的线程中,可能执行不同的系统调用,线程的运行是独立的,为了保证彼此之间系统调用的错误码不相互干扰,变量errno也应是线程独立的。 - 调度优先级 线程的调度和运行是独立的,每个线程都有自己独立的调度优先级。
- 信号屏蔽字(block表) 每个线程都可以设置自己的信号屏蔽字,用于决定哪些信号可以被这个线程接收和处理(本质是进程接收和处理)。信号屏蔽字是线程独立的,每个线程都可以对自己接收到的信号做选择,类似于一个筛网,用于决定自己收到的哪些信号可以最终被进程处理。
- 线程ID 线程ID唯一地标识了一个进程,线程ID是线程独立的。下文会看到,线程的 tid 本质是一个地址。
线程控制
pthread库
在 Linux 中,POSIX pthread
库是进行线程控制的原生方式。POSIX pthread库在所有类 unix 系统中适用。Linux中的线程是用进程进行模拟的,Linux 不会提供线程相关的系统调用接口,只提供轻量级进程(Light Weight Process)的系统调用接口,LWP是Linux执行流调度的基本单位,在纯系统角度,线程本质是一种轻量级进程。使用ps
命令的-L
选项查看当前系统中存在的轻量级进程,LWP是系统中轻量级进程的ID。
[@shr Thu Mar 21 20:32:02 3.21_cpp_thread]$ ps -aL
PID LWP TTY TIME CMD
31398 31398 pts/0 00:00:00 a.out #主线程的LWP与进程PID相同
31398 31399 pts/0 00:00:00 a.out
31408 31408 pts/1 00:00:00 ps
可以发现,主线程的 LWP与进程的PID相同。
系统中提供的轻量级进程的相关接口的使用相对复杂。为了方便用户操作,POSIX提供了一个位于应用层的pthread第三方库,对这些复杂的接口进行了封装。几乎所有的类 unix 平台都自带pthread库。下面会以对线程的操作为主线对pthread提供的接口进行讨论。
使用pthread_create(3)
创建一个线程:
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
//成功返回0,失败返回错误码,并且thread不被定义
- thread 是一个
pthread_t
类型的输出型参数,用于返回新建的线程的 tid。pthread_t 用于描述一个线程,在pthread库中,每个线程都有一个唯一的 pthread_t 类型的标识,这里将其称为 tid。tid 在 pthread 库中唯一的标识了一个线程。 - attr 用于设置新建的线程的属性,一般设置为 nullptr,使用默认属性。
- start_routine 指定线程的执行入口。线程的入口函数的参数和返回类型均要为
void*
。指定线程的入口,本质是在给线程分配代码资源。 - arg 用来给线程入口函数start_routine传递参数,如果没有参数,则将其置为 nullptr。
不考虑异常情况,线程退出一般有3种方式:
主线程return,这时进程结束,所有线程退出。
#include <pthread.h>
void pthread_exit(void *retval);
线程调用pthread_exit(3)
,调用者退出,退出状态可以设置为retval
。
#include <pthread.h>
int pthread_cancel(pthread_t thread);
//成功返回0,失败返回非0错误码
线程调用pthread_cancel(3)
使tid为 thread 的线程退出。当调用 pthread_cancel 时,系统会向指定线程发送一个取消请求,目标线程会在收到这个取消请求并处理后退出。
当一个未被分离的线程退出时,会进入僵尸状态,造成与僵尸进程类似的问题。当 pthread_exit 与 pthread_cancel 使主线程退出时,其他线程可以继续运行,只有主线程僵尸。当其他线程退出时,主线程需要对其进行等待。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
//成功返回0,失败返回错误码
在主线程使用 pthread_join 等待指定线程退出,retval 是一个输出型参数,用于带出线程的返回码。所指定的线程 thread 必须是可连接的(joinable)。pthread_join默认进行的是阻塞等待。join可以回收退出线程的资源,获取线程的退出信息,并保证主线程最后退出。在等待时不考虑异常情况,因为异常是进程层面的,当发生异常时,整个进程默认都会退出。
一个线程被创建时,默认是 joinable 可连接的,如果不想对线程进行等待,可以将这个线程进行分离(detach)。使用pthread_detach(3)
分离一个线程。
#include <pthread.h>
int pthread_detach(pthread_t thread);
//成功返回0,失败返回错误码
线程被分离后处于detach状态,退出后会自动清理自己的资源。一个线程不能同时为joinable和detach,线程被分离后不能被等待,用户需要自己保证主线程最后退出以使程序正常运行和结束。线程是否被分离其实是线程的一种属性。将线程分离,只需要指定线程的tid即可,可以在指定线程中进行线程分离,也可以在其他线程中进行线程分离。使用pthread_self(3)
获取当前线程的tid。
#include <pthread.h>
pthread_t pthread_self(void);
//函数调用总是成功,返回当前线程的tid
一个演示线程完整生命周期的demo程序大致如下:
typedef void* T;
uint64_t exitcode = 6;
void *routine(T args)
{
uint64_t n = (uint64_t)args;
for(uint64_t i = 1; i <= n; ++i) {
cout << i << "> i am a thread, tid:" << pthread_self() << endl;
}
pthread_exit((T)exitcode);
}
int main()
{
pthread_t th;
uint64_t n = 100;
pthread_create(&th, nullptr, routine, (T)n);
uint64_t retval = 0;
pthread_join(th, (T*)&retval);
printf("thread exit, code:%ld\n", retval);
return 0;
}
线程tid与地址空间布局
现在对pthread库与用户级线程、内核级线程做一深入讨论。动态库在运行时,一定要被加载到内存,并进一步被映射到地址空间中的共享区,pthread也不例外。同时,线程库是运行在操作系统之上的,是由用户进行维护的。上文说到,线程库是对轻量级进程相关系统调用的封装,他们两者的关系为:线程库是上层的,维护的是线程概念(属性),不维护线程执行流,线程执行流本质是一个轻量级进程,是由操作系统维护的。使用pthread库创建线程,创建的是内核级线程(lwp),pthread库对lwp的属性和接口在用户层面进行封装,使用户对这些lwp的操作更加方便。在Linux中,用户级线程与内核中的lwp数量是一一对应的。
线程库要对多个线程进行管理,首先要对线程进行抽象描述,类似进程控制快,线程的**tcb
**包含了线程的各个属性字段,线程库只需要以某种数据结构对tcb进行管理即可。每一个库级别的线程tcb的首地址,即是线程的tid,即tid的本质是一个地址。进行线程操作时,只要拿到tid,即线程tcb的首地址,既可以整个对tcb进行修改。tid是用户层面的,除了主线程,其他所有用户的所有线程都在pthread中被维护。
承上,下面要讨论线程tcb中包含的两个关键部分:线程局部存储和线程栈。
进程的数据段是所有线程共享的,对于一个全局变量,任何一个线程对其的修改都是统一的。如果要定义一个变量,这个变量对所有线程都是可见的,但是每个线程对其的修改都是独立的,线程的局部存储技术可以做到这一点。使用gcc的扩展关键字__thread
定义线程局部存储变量,本质是将这个变量放到了线程tcb中的线程局部存储空间,相当于定义了一个线程级别的全局变量。局部存储只能定义C/C++的内置类型。
//***
//普通全局变量
//int n = 0;
//***
__thread int n = 0;
int cnt = 10;
void* routine_1(T args)
{
for(int i = 0; i < cnt; ++i) {
cout << "i am th1, tid:" << pthread_self() << ", cur num: " << n++ << endl;
}
}
void* routine_2(T args)
{
for(int i = 0; i < cnt; ++i) {
cout << "i am th2, tid:" << pthread_self() << ", cur num: " << n++ << endl;
}
}
int main()
{
pthread_t th1;
pthread_t th2;
pthread_create(&th1, nullptr, routine_1, nullptr);
pthread_create(&th2, nullptr, routine_2, nullptr);
pthread_join(th1, nullptr);
pthread_join(th2, nullptr);
return 0;
}
//运行结果
//***
//普通全局变量
//i am th1, tid:i am th2, tid:139844124174080, cur num: 139844115781376, cur num: 00
//i am th1, tid:139844124174080, cur num: 1
//i am th1, tid:139844124174080, cur num: 2
//i am th1, tid:139844124174080, cur num: 3
//i am th1, tid:139844124174080, cur num: 4
//i am th1, tid:139844124174080, cur num: 5
//i am th1, tid:139844124174080, cur num: 6
//i am th1, tid:139844124174080, cur num: 7
//i am th1, tid:139844124174080, cur num: 8
//i am th1, tid:139844124174080, cur num: 9
//
//i am th2, tid:139844115781376, cur num: 11
//i am th2, tid:139844115781376, cur num: 12
//i am th2, tid:139844115781376, cur num: 13
//i am th2, tid:139844115781376, cur num: 14
//i am th2, tid:139844115781376, cur num: 15
//i am th2, tid:139844115781376, cur num: 16
//i am th2, tid:139844115781376, cur num: 17
//i am th2, tid:139844115781376, cur num: 18
//i am th2, tid:139844115781376, cur num: 19
//***
i am th1, tid:140084059956992, cur num: 0
i am th1, tid:140084059956992, cur num: 1
i am th1, tid:140084059956992, cur num: 2
i am th1, tid:140084059956992, cur num: 3
i am th1, tid:140084059956992, cur num: 4
i am th1, tid:140084059956992, cur num: 5
i am th1, tid:140084059956992, cur num: 6
i am th1, tid:140084059956992, cur num: 7
i am th1, tid:140084059956992, cur num: 8
i am th1, tid:140084059956992, cur num: 9
i am th2, tid:140084051564288, cur num: 0
i am th2, tid:140084051564288, cur num: 1
i am th2, tid:140084051564288, cur num: 2
i am th2, tid:140084051564288, cur num: 3
i am th2, tid:140084051564288, cur num: 4
i am th2, tid:140084051564288, cur num: 5
i am th2, tid:140084051564288, cur num: 6
i am th2, tid:140084051564288, cur num: 7
i am th2, tid:140084051564288, cur num: 8
i am th2, tid:140084051564288, cur num: 9
上文说到,线程的线程栈是线程独立的,本质是在线程tcb中另外使用了一块空间用作独立栈。每个执行流的本质是一条调用链,使每个线程都有一个线程栈,可以避免线程的调用链之间相互干扰。需要注意的是,线程的线程栈是在地址空间的共享区被管理的,而进程的堆空间是线程共享的,所以线程的线程栈是独立的,但不是私有的,线程之间没有秘密。