目录
线程与进程
在 Linux 下程序或可执行文件是一个静态实体,它只是一组指令的集合,没有执行的含义。进程是一个动态的实体,有自己的生命周期。线程是操作系统进程调度可调度的最小执行单元,进程和线程的关系可归为四类:
- 单线程进程,这就是传统意义上的进程
- 多线程进程
- 多个单线程进程
- 多个多线程进程
为什么要有多线程:简单点说是为了实现并发编程模型,提高机器工作效率。生活中我们去某个机构办理业务时不希望这里只有一个窗口,而是希望窗口越多越好,因为有多个窗口同时处理业务能让我们排队等候的时间变短,整体工作效率也就提高了,这其实就是生活中并发模型。
提出多线程之后随之而来的一个问题是:多个进程也能实现并发编程,为什么又要出现多线程呢。首先多线程和多进程是有区别的,都要自己的特点,进而有各自的优缺点,进而有各自更适合出场的场景。
进程之间,各自的地址空间是独立的,但线程会共享地址空间。同一个进程的多个线程共享一份全局内存区域,包括初始化数据段、未初始化数据段和动态分配的堆内存段。
这种共享给线程带来了很多优势:
- 创建线程花费的时间要小于创建进程花费的时间
- 终止线程花费的时间要小于终止进程花费的时间
- 线程之间上下文切换的开销要小于进程之间上下文切换开销
- 线程之间数据的共享要比进程之间的共享简单
- 线程之间通信的代价小于进程之间通信的代价
你可以做一个简单实验:分别创建10万个进程和创建10万个线程,比较二者时间上的开销。
创建进程的测试程序将会执行如下操作:
- 调用 fork 函数创建子进程,子进程无实际操作,调用 exit 函数立即退出,父进程等待子进程退出
- 重复步骤1,共执行10万次
创建线程的测试程序将会执行如下操作:
- 调用 pthread_create 创建线程,线程无实际操作,调用 pthread_exit 函数立刻退出,主线程调用 pthread_join 函数等待线程退出
- 重复步骤1,执行10万次
别人机器上对本测试的结论是:创建线程花费的时间约是创建进程花费时间的五分之一。
在上述测试中,调用 fork 函数和 pthread_create 函数之前,并没有分配大块内存,考虑到创建进程需要拷贝页表,而创建线程不需要,则两者之间效率上的差距将会进一步拉大。
别人机器上的测试的结论是:创建线程和进程之前,堆上分配40GB空间,创建线程花费的时间约是创建进程花费时间的五十分之一。
线程间的上下文切换,指的是同一个进程里不同线程之间发生的上下文切换。由于这些线程原本属于同一个进程,他们是共享地址空间的,大量资源共享,切换的代价小于进程间的切换是自然而然的事情了。
没有银弹,多线程带来优势的同时,也存在一些弊端。
- 多线程的进程,因地址空间的共享让该进程变得更加脆弱。多个线程之中,只要有一个线程不够健壮存在 bug,就会导致进程内的所有线程一起完蛋。相比之下,进程的地址空间相互独立,彼此隔离的更加彻底。多个进程之间相互协同,一个进程存在 bug 导致异常退出,不会影响到其他进程。
- 线程模型作为一种并发的编程模型,效率并没有想象的那么高,会出现复杂度高、易出错、难以测试和定位的问题。
目前存在的并发编程,基本可以分为两类:
- 共享状态式,这是线程模型采用的方式
- 消息传递式
首先,多个线程之间,存在负载均衡的问题,现实中很多情况下很难将全部任务等分给每个线程。想象一下,如果存在10个线程,其中的某一个线程承担了 80% 的任务,其它的9个线程承担了 20% 的任务,整体的效率就降下来了。
其次,多个线程的任务之间还可能存在顺序依赖关系,一个线程未完成某些操作之前,其他线程不能或比应该执行。
多个线程之间需要同步。多个线程在同一进程的地址空间下,若存在多个线程操作共享资源,则需要同步,否则可能会出现结果错误、数据结构遭到破坏甚至程序崩溃的后果。多线程编程中存在临界区的概念,临界区的代码只允许一个线程执行,线程提供锁机制来保护临界区。当其他线程来到临界区却无法申请到锁时,就可能陷入阻塞,不再处于可执行状态,线程可能不得不让出 CPU 资源。如果设计不合理,临界区非常多,线程之间的竞争异常激烈,频繁的上下文切换导致性能急剧恶化。
上面两种情况的存在,决定了多线程并非总是处于并发状态,加速也并非线性的。四个线程未必能带来四倍的工作效率,加速取决于可以串行执行的部分在全部工作中所占的比例。
由于进程调度的无序性,严格来说多线程程序的每次执行其实并不一样,很难群举所有的时许组合,所以我们无法宣称多线程的程序经过了充分的测试。在某些特殊时许的条件下,bug 可能会出现,这种 bug 难以复现,很难排查。所以编程时,需要谨慎设计,以确保程序能够在所有时许条件下正常运行。
对于多线程编程,还存在四大陷阱:
- 死锁
- 饿死
- 活锁
- 竞态条件
进程 ID 和线程 ID
在 Linudex 中,目前的线程实现是 Native POSIX Thread Library,简称 NPTL。在这种实现中线程又被称为轻量级进程,每一个用户态的线程在内核中都对应一个调度实体,有属于自己的 task_struct 结构体。
一个用户进程下有 N 个用户态线程,每个线程作为独立的调度实体在内核中都有自己的 task_struct 结构体,用户进程和内核里的进程描述符是 1:N 的关系,POSIX 标准要求进程内的所有线程调用 getpid 时返回相同的 ID,这个 ID 是用户进程 ID。
task_struct 结构体(部分):
struct task_struct{
...
pid_t pid;
pid_t tgid;
...
struct task_struct *group_leader;
...
struct list_head thread_group;
...
}
多线程的进程,又称为线程组,线程组内的每一个线程在内核中都对应一个进程描述符(task_struct 结构体)。task_struct 结构体中 pid 对应的是线程 ID,tgid 对应的是用户进程 ID。
这里介绍的线程 ID 不同于后面讲到的 pthread_t 类型的线程 ID,和进程 ID 一样,线程 ID 是 pid_t 类型的变量,而且是唯一标识线程的一个整形变量,在系统内是独立的。
查看线程 ID:
- PID :用户进程 ID
- LWP :线程 ID,即 gettid() 系统调用的返回值
- NLWP :线程组内线程的个数
已知进程 ID,查该进程内线程的个数及其线程 ID:procfs 在 task 下会给进程的每个线程建立一个子目录,目录名为线程 ID
线程组内的第一个线程在用户态被称为主线程,在内核中被称为 Group Leader。内核在创建第一个线程时,会将线程组 ID 的值设置成第一个线程的线程 ID,greap_leader 指向自身,即主线程的进程描述符。所以线程组内存在一个线程 ID 等于进程 ID,而该线程即为线程组的主线程。
至于线程组的其他线程 ID 由内核负责分配,其线程组的 ID 总是和主线程的线程 ID 一致,无论是主线程直接创建出来的线程还是创建出来的线程再次创建的线程,都是这样。
通过 group_leadre 指针,每个线程都能找到主线程。主线程存在一个链表头,后面创建的每一个线程都会链入到该双向链表中。
利用上述结构,每个线程都可以轻松找到线程组的主线程,另一方面,通过线程组的主线程,也可以轻松的遍历其所有的组内线程。
需要强调的一点是,线程和进程不一样,进程有父子进程的概念,但在线程组里所有的线程都是对等关系:
- 并不是只有主线程才能创建线程,被创建出来的线程同样可以创建线程
- 不存在父子关系,大家都属于同一个线程组,进程 ID 都相等,group_leader 都指向主线程,而且各有各的线程 ID
- 并非只有主线程才能调用 pthread_join 连接其他线程,同一线程组内的任何线程都可以对某线程执行 pthread_join 函数
- 并非主线程才能调用 pthread_detach 函数,其实任意线程都可以对同一线程组内的线程执行分离操作
pthread 库接口
pthread 库的基本接口包括:
- pthread_create :创建一个线程
- pthread_exit :退出线程
- pthread_self :获取线程 ID
- pthread_equal :检查两个线程 ID 是否相等
- pthread_join :等待线程退出
- pthread_detach :设置线程状态为分离状态
- pthread_cancel :线程的取消
- pthread_cleanup_push pthread_cleanup_pop :线程取消,清理函数注册和执行
线程的创建和标识
pthread_create 函数
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg)
参数介绍
- thread :pthread_t 类型的指针,线程创建成功的话,会将分配的线程 ID 添入该指针指向的地址。线程后续的操作将该值作为线程的唯一标识
- attr :pthread_attr_t 类型,通过该参数可以定制线程属性,比如可以指定新建线程栈空间的大小,调度策略等。如果要创建的线程无特殊要求,该值设置成 NULL,标识采用默认属性
- start_routine :线程需要执行的函数。创建线程是为了让线程执行特定的任务。线程创建成功之后,该线程就会执行 start_routinue 函数,该函数之于线程,就如同 main 函数之于主线程
- arg :线程执行 start_routine 函数的参数。当执行函数需要传入多个参数时,线程创建者(一般是主线程)和新建线程约定一个结构体,创建者把信息填入该结构体,再把结构体的指针传给新建线程,新建线程只要解析这个结构体,就能获取到需要的所有参数
返回值
如果创建线程成功,则返回 0;如果不成功,则返回一个非 0 的错误码(判断该函数返回值是要注意)。常见的错误码如下:
- EAGAIN :系统资源不够,或者创建线程的数量超过系统对一个进程中线程总数的限制
- EINVAL :第二个参数 attr 值不合法
- EPERM :没有合适的权限来设置调度策略和参数
线程 ID 及进程地址空间布局
两类线程 ID 说明
pthread_create 函数会产生一个 pthread_t 类型的线程 ID,存放在第一个参数指向的空间内。这里的线程 ID 和前面提到的 pid_t 类型的线程 ID 我们该如何去定位或者看待呢?
pid_t 类型的线程 ID :属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来在整个操作系统内唯一标识该线程。
pthread_t 类型的线程 ID :属于 NPTL 线程库的范畴,线程库的后续操作,就是根据该线程 ID 来操作线程的。对于 Linux 目前使用的 NPTL 实现而言,pthread_t 类型的 ID 本质上是进程地址空间上的一个地址。
线程库 NPTL 提供 pthread_self 函数,可以获取到线程自身的 ID:
#include <pthread.h>
pthread_t pthread_self(void);
在同一个线程组内的,线程库提供了接口,可以判断两个线程 ID 是否对应着同一个线程:
#include <pthread.h>
int pthread_equal(pthread_t t1, pthread_t t2);
返回值是 0 的时候,表示是同一个线程,非 0 则表示不是同一个线程。
进程地址空间
对于进程地址空间的布局,系统有如下控制选项:
该选项影响进程地址空间的布局,主要是影响 mmap 区域的基地址位置以及 mmap 生长方向两个方面。如果该值为 1,那么 mmap 的基地址 mmap_base 变小(约在用户地址空间的三分之一处),mmap 区域从低地址向高地址扩展。如果该值为 0,那么 mmap 区域的基地址在栈的下面,mmap 区域从高地址向低地址扩展。默认值为 0,布局如下:
可以通过下面两种方式来查看进程地址空间分布:
1. pmap pid
2. cat /proc/pid/maps