Linux系统:线程介绍与POSIX线程库实现线程控制
目录
一、基本概念与特性
1.1 基本概念
1.2、核心特征(“独立”和“共享”)
二、线程的优点
三、线程异常
四、线程的本质:轻量化进程
4.1 什么是轻量化进程
3.2 核心特性
3.3 实现原理
3.4 线程与“轻量化进程”
五、POSIX线程库
5.1 线程管理接口
5.2 创建多线程
六、线程ID与进程地址空间布局
七、线程封装
一、基本概念与特性
1.1 基本概念
在 Linux 操作系统中,线程(Thread) 是进程(Process)内的一个独立执行单元,是操作系统进行任务调度和资源分配的基本单位之一。它与进程共享大部分资源(如内存空间、文件描述符等),同时又能独立执行代码,是实现 “并发执行” 的核心技术之一。
理解线程时我们需要注意以下几点:
- 一个进程必须至少有一个线程执行流
- 线程在进程中运行,准确来讲是在进程地址空间中运行
1.2、核心特征(“独立”和“共享”)
线程的核心特点可以概括为 “共享进程资源,独立执行逻辑”。
1. 线程与所属进程所属进程的其他线程共享大部分资源,无需额外复制
- 虚拟地址空间:共享进程的代码段、数据段、堆,线程间访问内存无需通过 IPC(进程间通信),直接读写即可。
- 文件描述符表:共享进程打开的所有文件、网络连接、管道等(如一个进程打开一个 socket,其下所有线程都能使用该 socket 收发数据)。
- 进程级属性:如进程 ID(PID)、用户 ID(UID)、组 ID(GID)、信号处理器(Signal Handler)、工作目录等。
- 其他资源:如共享内存段、消息队列、信号量等进程级 IPC 资源。
这里需要注意的是线程作为进程内部的执行流对同一个进程的虚拟地址空间是共享的,而虚拟地址空间是资源分配的窗口,通过虚拟地址空间线程就能进行资源间的共享。
2.线程独立拥有的资源:
为了实现 “独立执行”,每个线程必须有自己的专属上下文,避免与其他线程的执行逻辑冲突:
- 线程 ID(TID):Linux 内核中每个线程都有唯一的 TID(不同于进程的 PID),用于内核识别和调度。
- 寄存器集合:存储线程执行时的临时数据(如 CPU 寄存器中的值),切换线程时需保存 / 恢复这部分数据。
- 栈空间(Stack):每个线程有独立的用户栈(默认大小通常为 8MB,可通过
ulimit -s
查看 / 修改),用于存储局部变量、函数调用栈帧(避免多线程栈数据混乱)。 - 信号掩码(Signal Mask):线程可独立屏蔽部分信号(如线程 A 屏蔽 SIGINT,不影响线程 B 接收该信号)。
关键点:线程具有自己的上下文和独立的栈空间
二、线程的优点
1.创建一个新线程的代价远小于创建一个新进程
理解这一点的关键在于理解线程的复用机制,线程作为进程内的执行单元,会直接复用所属进程的大部分资源,包括:
- 共享进程的虚拟内存空间(代码、数据、堆),无需重新分配。
- 共享进程的文件描述符、信号处理方式等。
- 仅需新增少量私有资源,如线程控制块(TCB)、独立的栈空间(用于函数调用),资源开销极低。
而创建一个新进程时操作系统会必须为其分配一整套独立资源,包括但不限于:
- 独立的虚拟内存空间(代码段、数据段、堆、栈)。
- 独立的文件描述符表、信号处理表、进程控制块(PCB)。
- 这些资源的分配和初始化需要消耗大量 CPU 时间和内存空间。
2.与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
这一点可以从两方面来理解:
- 线程本身就是进程的一部分,线程之间共享一个虚拟地址空间在切换的时候不需要更改页表。而进程切换涉及大量的寄存器内容的切换,这就导致了大量的性能损耗。
- 线程切换不会影响CPU的缓存机制,例如TLB快表与cache缓存。但是进程切换导致页表的切换使得TLB的内容全被刷新使得在内存映射在一段时间内变得相当低效。
线程占用的资源要比进程少很多并且能充分利用多处理器的可并行数量,在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
三、线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程 终止,该进程内的所有线程也就随即退出
四、线程的本质:轻量化进程
4.1 什么是轻量化进程
在Linux内核中并不存在“线程”的概念,只有轻量化进程。轻量化进程( LWP) 是内核级线程的实现方式,本质上是一种由内核管理的、共享部分资源的执行单元。它是 Linux 对 “线程” 概念的具体实现
也就是在用户层面我们将轻量化进程叫做线程,在内核层面不存在线程这种概念或特殊的数据结构只有轻量化进程的概念。我们理解了轻量化进程的核心特点与概念我们就从底层角度“吃透了线程”。
3.2 核心特性
LWP 本质是 Linux 内核对 “线程” 的实现,其核心特性围绕 “共享资源 + 独立执行” 展开:
1. 同一个进程中的轻量级进程共享大部分资源:
多个 LWP 属于同一个 “进程组”,共享以下关键资源:
- 地址空间:代码段(.text)、数据段(.data/.bss)、堆(heap)由所有 LWP 共享,因此一个 LWP 修改堆内存,其他 LWP 可见;
- 文件描述符表:打开的文件、socket、管道等文件描述符(fd)由所有 LWP 共享,一个 LWP 打开的文件,其他 LWP 可通过相同 fd 操作;
- 信号处理:信号的处理方式(如忽略、捕获、默认动作)由进程组统一配置,内核会将信号发送到 “目标 LWP”(如指定 LWP 或空闲 LWP);
- 进程环境变量:
environ
变量由所有 LWP 共享,修改环境变量对整个进程组生效; - 用户 / 组权限:UID、GID 等权限信息属于进程组,所有 LWP 权限一致
2. 拥有独立的执行上下文
- 每个 LWP 有自己的
task_struct
(PCB),包含独立的执行相关数据,确保能独立被内核调度: - 寄存器集合:包含通用寄存器(如 eax)、状态寄存器,切换 LWP 时需保存 / 恢复这些寄存器;
- 内核栈:每个 LWP 有独立的内核栈(默认 8KB),用于执行内核态代码(如系统调用);
- 用户栈:虽然地址空间共享,但每个 LWP 有独立的用户栈(通常在堆的下方或通过
mmap
分配),用于执行用户态代码,避免函数调用栈冲突; - PID:每个 LWP 有独立的 PID(进程 ID),用于内核唯一标识(注意:用户态通过
getpid()
获取的是tgid
,即进程组 ID,而非 LWP 的 PID)。
3. 内核级调度与多核支持
- LWP 是 Linux 内核调度的基本单位(即 “调度实体”):
- 内核会将每个 LWP 视为独立的 “可调度单元”,根据调度策略分配 CPU 时间片;
- 多个 LWP 可同时在不同 CPU 核心上执行(并行),充分利用多核资源
- 当一个 LWP 因 I/O 阻塞(如
read
、sleep
)时,内核会调度其他 LWP 执行,不会导致整个进程组阻塞(这是 LWP 相比传统进程的核心优势)。
3.3 实现原理
与其他操作系统不同的是,Linux操作系统在设计轻量化进程的时候主要参考了进程的设计思想,这样的好处是代码复用可以大大减少开发设计周期,还能保证操作系统的协调性。
Linux 内核通过 task_struct
(PCB)的两个关键字段实现 LWP 与进程的区分:
tgid
:标识 LWP 所属的 “进程组”。对于传统进程,tgid
等于自身的pid
;对于 LWP,多个 LWP 的tgid
相同(指向进程组的 “领头进程” PID)。mm
:指向进程的地址空间描述符。传统进程的mm
是独立的;多个 LWP 共享同一个mm
,因此共享地址空间。
因为轻量化进程是线程操作系统中的底层实现,所以操作系统不提供“线程”的系统调用而是只提供轻量化进程的系统调用,比如clone()表示创建一个轻量化进程,当我们执行clone()系统调用是,操作系统会执行以下操作:
- 复制一个新的
task_struct
(但比fork()
轻量化,因为不复制mm
、文件描述符表等资源); - 将新
task_struct
的tgid
设置为父 LWP 的tgid
(加入同一进程组); - 将新
task_struct
的mm
指向父 LWP 的mm
(共享地址空间),并将mm
的引用计数加 1; - 为新 LWP 分配独立的内核栈和用户栈;
- 将新 LWP 加入内核调度队列,等待被调度执行。
3.4 线程与“轻量化进程”
Linux操作系统只提供轻量化进程的系统调用,那么我们对线程进行一系列控制操作该使用什么接口呢,难道用的是轻量化进程的系统调用吗?答案是:是也不是
我们所讲的线程准确来说应该是“用户级线程”,而轻量化进程的系统调用更加触及内核而且涉及到的系统调用大多比较复杂,比如clone()
需要传入一系列标志位(如 CLONE_VM
共享地址空间、CLONE_FILES
共享文件描述符等)来控制资源共享范围,普通用户很难正确设置这些标志。同时,Linux操作系统提供的系统调用并不能完全覆盖线程控制的各个接口,比如线程同步( mutex、条件变量)、线程属性(优先级、栈大小)、线程销毁等功能内核无法提供相关接口。
为了解决这些问题,Linux系统将轻量化进程提供语言库的封装来实现“用户级线程”,这个库就是Linux 的 pthread
库(POSIX 线程库)。pthread库提供标准化、易用的线程接口,屏蔽内核细节,同时补充同步、生命周期管理等核心功能,让开发者能轻松编写可移植的多线程程序。
五、POSIX线程库
5.1 线程管理接口
1. pthread_create
(创建线程)
int pthread_create(pthread_t *restrict thread, // 输出参数:新线程的ID(唯一标识)const pthread_attr_t *restrict attr, // 输入参数:线程属性(NULL表示默认属性)void *(*start_routine)(void *), // 输入参数:线程入口函数(函数指针)void *restrict arg // 输入参数:传给入口函数的参数
);
返回值:
- 成功:
0
- 失败:非 0 错误码(如
EAGAIN
资源不足、EINVAL
属性无效)
#include <pthread.h>
#include<iostream>
#include <cstdio>
#include<unistd.h>void Showid(pthread_t tid){printf("tid: %ld\n",tid);}
// 线程入口函数:接收一个整数参数并打印
void *print_num(void *arg) {std::string name=static_cast<const char*>(arg);// 解析参数int cnt=5;while(cnt){std::cout<<"我是一个新线程,我的名字是:"<<name<<std::endl;sleep(1);cnt--;}return NULL;
}int main() {pthread_t tid; // 用于存储新线程IDint ret;// 创建线程:使用默认属性,入口函数为print_num,参数为&argret = pthread_create(&tid, NULL, print_num, (void*)"thread-1");id_t pid = getpid(); // 获取当前进程PIDprintf("当前进程的PID是:%d\n", pid);Showid(tid);if (ret != 0) {printf("pthread_create failed: %d\n", ret); // 错误处理return 1;}pthread_join(tid, NULL); // 等待线程结束return 0;
}
thread
参数会被填充新线程的 ID,后续可用pthread_join
等函数操作该线程。arg
是传给线程的参数,若传递多个参数需用结构体封装。
此时我们执行如下命令查询进程code:
ps axj | head -1 && ps axj |grep code
然后我们查询
ps -aL
这里的两个code的PID相同表示两个线程同属于code进程,LWP小的表示主线程另一个表示我们创建的新线程,主线程的LWP与PID相同。这里我们就会发现创建的线程的打印的tid(140331056272960)与自己的LWP(1359725)并不相同。
怎么理解这个“tid”呢?这个“tid”是pthread库给每个线程定义的进程内唯⼀标识,是pthread库维持的。 由于每个进程有自己独立的内存空间,故此“tid”的作用域是进程级而非系统级(内核不认识)。LWP是什么呢?LWP得到的才是内核中真正的线程ID。
我们前面说过在Linux系统中线程由底层的轻量级进程模拟,所以线程又叫用户级线程。在一般情况下轻量级进程的相关概念并不会向用户暴漏,核心原因是操作系统通过抽象层隐藏了底层线程实现细节,让用户无需关注内核级线程管理,只需聚焦上层业务逻辑,大大降低了编程难度。
2. pthread_join
(等待线程结束)
int pthread_join(pthread_t thread, // 输入参数:目标线程ID(由pthread_create返回)void **retval // 输出参数:接收线程的返回值(pthread_exit的参数)
);
- 成功:
0
- 失败:非 0 错误码(如
EINVAL
线程不可 join、ESRCH
线程不存在)
#include <pthread.h>
#include <stdio.h>void *return_value(void *arg) {static int result = 456; // 用static避免栈内存释放return &result; // 线程返回值
}int main() {pthread_t tid;void *ret; // 用于接收线程返回值int ret_code;pthread_create(&tid, NULL, return_value, NULL);ret_code = pthread_join(tid, &ret); // 阻塞等待线程结束if (ret_code != 0) {printf("pthread_join failed: %d\n", ret_code);return 1;}printf("Thread returned: %d\n", *(int*)ret); // 打印返回值456return 0;
}
这里需要注意的是,在进程join的时候并没有异常相关的字段。这时因为,要是进程出异常main线程相应就会退出,所以讨论线程异常没有意义。
3. pthread_detach
(设置线程为分离态)
线程默认都是需要等待的如果主线程不再关心新线程的返回情况,想让新线程结束的时候自动回收相应资源就将新线程设置为分离状态。
设置的方式主要有两种:一种是主动分离,二是被动分离
主动分离是指新线程在内部通过调用接口将自己设置为分离状态,而被动分离通常是指主线程将其他线程设置为分离状态。
int pthread_detach(pthread_t thread); // 输入参数:目标线程ID
功能:将其他线程thread设置为分离状态或将自己设置为分离状态
- 成功:
0
- 失败:非 0 错误码(如
ESRCH
线程不存在
被动分离:
#include <pthread.h>
#include <stdio.h>void *detached_thread(void *arg) {printf("This is a detached thread\n");return NULL;
}int main() {pthread_t tid;int ret;pthread_create(&tid, NULL, detached_thread, NULL);ret = pthread_detach(tid); // 将tid设置为分离态if (ret != 0) {printf("pthread_detach failed: %d\n", ret);return 1;}// 分离态线程无需pthread_join,主线程可直接退出return 0;
}
主动分离:
#include <pthread.h>
#include <stdio.h>void *detached_thread(void *arg) {printf("This is a detached thread\n");ret = pthread_detach(pthread_salf()); // 将自己设置为分离态if (ret != 0) {printf("pthread_detach failed: %d\n", ret);return 1;}return NULL;
}int main() {pthread_t tid;int ret;pthread_create(&tid, NULL, detached_thread, NULL);// 分离态线程无需pthread_join,主线程可直接退出return 0;
}
分离态线程结束后自动释放资源,无需调用pthread_join
,否则会报错。
4.pthread_exit
&pthread_cancel(
线程终止)
在一般情况下,当我们通过pthread_create
创建新线程后会通过函数中return来说明函数调用结束,线程终止。但是我们也可以通过POSIX库中的接口来主动终止本线程或者某一线程。
pthread_exit
是线程主动终止自身执行的函数,常用于线程完成任务后正常退出。
#include <pthread.h>
void pthread_exit(void *retval);
- 参数:
retval
是线程的退出状态(返回值),类型为void*
,可被其他线程通过pthread_join
获取。 - 返回值:无返回值(线程调用后直接终止)。
关键注意事项
retval
的有效性:retval
不能指向线程的局部变量(线程退出后局部变量会被释放),通常使用全局变量、动态分配内存或NULL
。- 与
return
的区别:在线程函数中return
相当于隐式调用pthread_exit
(返回值作为retval
),但在子函数中return
仅退出子函数,不终止线程;而pthread_exit
无论在何处调用,都会直接终止当前线程。 - 分离线程的
retval
:若线程被标记为 “分离态”,retval
会被忽略(无法通过pthread_join
获取)。
代码实例:
#include <stdio.h>
#include <pthread.h>void *thread_func(void *arg) {int result = 100; // 局部变量(注意:此处仅示例,实际不应返回局部变量地址)printf("子线程执行完毕,准备退出\n");pthread_exit((void*)&result); // 主动退出,返回状态
}int main() {pthread_t tid;pthread_create(&tid, NULL, thread_func, NULL);void *retval;pthread_join(tid, &retval); // 获取子线程退出状态printf("子线程返回值:%d\n", *(int*)retval); // 输出:100return 0;
}
pthread_cancel
用于一个线程请求终止另一个线程(被动取消),但目标线程是否响应、何时响应,取决于其自身的 “取消设置”,默认为允许取消。
#include <pthread.h>
int pthread_cancel(pthread_t thread);
- 参数:
thread
是目标线程的 ID(pthread_t
类型)。 - 返回值:成功返回
0
;失败返回非 0 错误码(如ESRCH
表示目标线程不存在)。
如果线程被取消,返回值默认为-1(PTHREAD_CANCELED)。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>void *thread_func(void *arg) {printf("子线程启动,允许取消(延迟模式)\n");// 延迟取消(默认),会在sleep(取消点)响应取消请求sleep(10); // 若被取消,此处不会执行完10秒printf("子线程正常结束(不会执行)\n");return NULL;
}int main() {pthread_t tid;pthread_create(&tid, NULL, thread_func, NULL);sleep(2); // 等待2秒后取消子线程printf("主线程发送取消请求\n");pthread_cancel(tid);void *retval;pthread_join(tid, &retval);if (retval == PTHREAD_CANCELED) {printf("子线程被取消\n"); // 输出:子线程被取消}return 0;
}
5.2 创建多线程
在这一小节我们试着创建多线程,代码如下:
void* routine(void* args)
{char* name=static_cast<char*>(args);int cnt=5;std::cout<<"我是一个新线程,我的名字是:"<<name<<std::endl;sleep(5);return nullptr;
}int main()
{std::vector<pthread_t> arr;int num=10;for(int i=0;i<num;i++){pthread_t tid;char name[64];snprintf(name,sizeof(name),"thread-%d",i);int ret=pthread_create(&tid,nullptr,routine,name);if(ret!=0){std::cout<<"pthread_create fail!"<<std::endl;exit(-1);}arr.push_back(tid);}for(int i=0;i<10;i++){pthread_join(arr[i],nullptr);}return 0;
}
运行代码后我们执行查询操作:
ps -aL
此时我们一共创建了10个新线程,算上主线程一共11个线程。上面代码的运行结果如下:
此时我们可以发现一个问题那就是每个线程的名字并没有按照我们的设想thread-0,thread-1,thread-2......thread-9按顺序分配起来,这时因为在代码中传递给线程的名字是主线程栈上的局部变量,栈上的局部变量的地址通常是相同的且被循环重复修改,导致线程读取时数据已被覆盖。
解决方法就是要让每个线程拿到独立且持久的名字,需避免 “共享栈上的临时变量”,我们可以为每个线程在堆上动态分配一块内存空间用来存储线程名字,此时虽然内存空间对所有线程是可见的但是只有本线程才能拿到自己的内存地址。这样就避免了多线程访问公共资源导致的线程安全的问题。
void* routine(void* args)
{char* name=static_cast<char*>(args);std::cout<<"我是一个新线程,我的名字是:"<<name<<std::endl;free(args);sleep(10);return nullptr;
}int main()
{std::vector<pthread_t> arr;int num=10;for(int i=0;i<num;i++){pthread_t tid;char* name=(char*)malloc(64);snprintf(name,64,"thread-%d",i);int ret=pthread_create(&tid,nullptr,routine,name);if(ret!=0){std::cout<<"pthread_create fail!"<<std::endl;exit(-1);}arr.push_back(tid);}for(int i=0;i<10;i++){pthread_join(arr[i],nullptr);}return 0;
}
运行结果:
六、线程ID与进程地址空间布局
在Linux系统中,实际上并不存在独立的线程概念,而是通过轻量级进程来实现线程功能。系统仅提供轻量级进程的相关接口,如vfork、clone等。需要注意的是,我们通常所说的"线程"实际上是由轻量级进程模拟实现的。具体来说,POSIX线程库通过封装轻量级进程的接口和概念,为开发者提供了一套完整的线程操作函数和抽象概念
我们注意到POSIX线程库并未向用户暴露轻量级进程的概念。例如,通过查询新线程的tid和LWP可以看到两者并不一致。这是因为LWP是Linux系统的内部概念,而tid则是POSIX线程库封装后的标识符。这种设计隐藏了底层线程的实现细节,使开发者无需关心内核级线程管理,能够专注于业务逻辑开发,从而显著降低了编程复杂度。
在这一小节,我们将讨论一下POSIX线程库中tid的底层细节,以及POSIX线程库在进程地址空间中与用户代码和数据的交互与链接:
当我们编译与POSIX线程库有关的可执行文件时必须在编译指令后加上-lpthread否则就会导致未定义行为。这是提前告诉动态链接器待编译文件需要pthread动态库,这时当我们编译可执行文件时就会将pthread加载入内存,并进行动态地址链接与动态地址重定向。
线程的大部分概念都在库中维护,当我们创建多线程时库中就需要维护多个线程。与系统维护进程信息类似在pthread库中会存在一个线程控制块TCB(通常对应struct pthread
结构体)用来管理单个线程的相关元数据包含以下信息:
-
线程 ID 与进程 ID
-
线程状态
-
内存与栈信息
-
执行上下文与状态
-
链表与同步
其中线程 ID 与进程 ID包括了内核中轻量级进程的LWP,所属进程的PID还有库中维护的pthread_t类型的tid。从底层角度理解,这个pthread_t tid就是库中对应线程的控制块TCB(也就是struct pthread
结构体)的起始虚拟地址,它通过这种方式标识了库中的唯一一个线程。如图:
当创建的新线程退出时会将退出信息写入线程控制块TCB中,此时虽然线程推出了但是对应的TCB并没有完全销毁,此时就需要主线程调用pthread_join回收新线程的退出信息,这时传递给pthread_join的pthread_t类型的参数就是待回收线程的TCB的起始虚拟地址,当拿到TCB中的退出信息后就会将待回收线程的TCB彻底销毁避免内存泄漏等问题。
前面我们说,线程是POSIX线程库对轻量级进程再封装后提出来的概念。我们又讲线程是调度的基本单位,但是我们在POSIX线程库的TCB中找不到关于调度相关的字段,那么它在哪里呢?其实当通过pthread_create创建一个新线程时除了在POSIX线程库中创建TCB外其还会调用系统调用(如clone)创建轻量级进程PCB。
此时pthread_create会将线程需要执行的方法,以及线程的栈结构告诉轻量级进程。当轻量级进程被CPU调度执行的时候我们实现设定的方法就会被执行,相关的临时变量就会存储在对应的栈结构之中了。
所以用户视角看到的是POSIX线程库中的线程概念,而真正在内核中调度的是轻量级进程。
七、线程封装
#include <pthread.h>
#include <iostream>
#include <string>
#include <functional>
std::uint32_t cnt = 1;
using threadfunc_t = std::function<void()>;
enum class TSTATUS
{THREAD_NEW,THREAD_RUNNING,THREAD_STOP
};
class Thread
{static void* run(void* arg){Thread* self=static_cast<Thread*>(arg);pthread_setname_np(pthread_self(), self->name.c_str());if(self->status==TSTATUS::THREAD_NEW){//设置线程状态:self->status=TSTATUS::THREAD_RUNNING;}//如果设置成了分离那就调用分离函数if(!(self->joined)){pthread_detach(pthread_self());}//调用相关函数:self->func();return nullptr;}
public:void Setname(){this->name="thread-"+std::to_string(cnt);cnt++;}std::string Getname(){return name;}Thread(threadfunc_t rout): func(rout), status(TSTATUS::THREAD_NEW), joined(true){Setname();}bool Start(){//一个线程不能连续运行两次//如果已经是运行状态就返回if(status==TSTATUS::THREAD_RUNNING){return;}//静态成员函数访问不了非静态成员函数所以传this指针int n=pthread_create(&tid,NULL,run,this);if(n!=0){std::cout<<"thread_creat fail!"<<std::endl;return false;}return true;}void EnableDetach(){if(status==TSTATUS::THREAD_NEW)joined=false;}void EnableJoined(){if(status==TSTATUS::THREAD_NEW)joined=true;}bool join(){if(joined){int n=pthread_join(tid,nullptr);if(n>0)return true;elsereturn false;}return false;}
private:// 线程名字:std::string name;// 线程idpthread_t tid;// 线程状态:TSTATUS status;// joinablebool joined;// 线程关联的函数:threadfunc_t func;
};