
🔥个人主页:爱和冰阔乐
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

🏠博主简介

文章目录
- 前言
- 一、线程栈:共享地址空间,不等于共用一块栈
-
- [1.1 主线程栈 vs 子线程栈](#1.1 主线程栈 vs 子线程栈)
- [1.2 代码验证:主线程访问子线程栈](#1.2 代码验证:主线程访问子线程栈)
- [二、先用 pthread_create 把线程模型跑出来](#二、先用 pthread_create 把线程模型跑出来)
-
- [2.1 创建第一个线程](#2.1 创建第一个线程)
- [2.2 编译时链接 pthread](#2.2 编译时链接 pthread)
- [2.3 用 ps -aL 看 LWP](#2.3 用 ps -aL 看 LWP)
- [2.4 三个实验结论](#2.4 三个实验结论)
- [三、Linux 为什么还需要 pthread 库](#三、Linux 为什么还需要 pthread 库)
-
- [3.1 Linux 的轻量级进程模型](#3.1 Linux 的轻量级进程模型)
- [3.2 pthread 库的作用](#3.2 pthread 库的作用)
- [3.3 C++11 的 thread](#3.3 C++11 的 thread)
- [四、pthread 线程控制:创建、等待、退出、取消与分离](#四、pthread 线程控制:创建、等待、退出、取消与分离)
-
- [4.1 创建线程](#4.1 创建线程)
- [4.2 线程等待](#4.2 线程等待)
- [4.3 线程 ID 与 pthread_self](#4.3 线程 ID 与 pthread_self)
- [4.4 线程返回值与传参传对象](#4.4 线程返回值与传参传对象)
- [4.5 线程终止的三种方式](#4.5 线程终止的三种方式)
- [4.6 线程分离](#4.6 线程分离)
- 总结
前言
前两篇把"线程是什么、为什么更轻"讲清楚,这一篇开始真正使用 pthread。
先看线程自己的栈,再创建线程、观察 PID/LWP,随后把 pthread 库为什么存在,以及创建、等待、返回、取消、分离这些接口一次串起来。这里仍然保持"先看现象,再解释原因"的写法。
一、线程栈:共享地址空间,不等于共用一块栈
1.1 主线程栈 vs 子线程栈
虽然 Linux 将线程和进程不加区分地统一到了 task_struct,但对待其地址空间的 stack 还是有区别的。
主线程的栈 :简单理解就是 main 函数的栈空间。在 fork 的时候,实际上就是复制了父亲的 stack 空间地址,然后写时拷贝(COW)以及动态增长。主线程使用的地址空间上的栈会自动扩容。如果扩充超出上限则栈溢出会报段错误。
子线程的栈 :不再是向下生长的,而是事先固定下来的。线程栈一般是调用 glibc/uclibc 等的 pthread 库接口 pthread_create 创建的线程,在文件映射区(共享区)。其中使用 mmap 系统调用:
cpp
mem = mmap(NULL, size, prot,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
这种 stack 不能动态增长,一旦用尽就没了。这是和生成进程的 fork 不同的地方。一般默认 8M。
在 glibc 中通过 mmap 得到 stack 之后,底层调用 sys_clone 系统调用:
cpp
int sys_clone(struct pt_regs *regs)
{
unsigned long clone_flags;
unsigned long newsp;
int __user *parent_tidptr, *child_tidptr;
clone_flags = regs->bx;
// 获取了 mmap 得到的线程的 stack 指针
newsp = regs->cx;
parent_tidptr = (int __user *)regs->dx;
child_tidptr = (int __user *)regs->di;
if (!newsp)
newsp = regs->sp;
return do_fork(clone_flags, newsp, regs, 0, parent_tidptr,
child_tidptr);
}
对于子线程的 stack,它是在进程的地址空间中 mmap 出来的一块内存区域,原则上是线程私有的。但同一个进程的所有线程生成的时候,会浅拷贝生成者的 task_struct 的很多字段,如果愿意,其他线程也还是可以访问到的。
1.2 代码验证:主线程访问子线程栈
cpp
int *p = nullptr;
void *run(void *args)
{
int a = 123;
p = &a; // 子线程把栈上变量地址暴露给全局指针
while (true)
{
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, run, nullptr);
sleep(1);
while (true)
{
std::cout << "*p:" << *p << std::endl; // 主线程读取子线程栈上的数据
sleep(1);
}
pthread_join(tid, nullptr);
return 0;
}

主线程成功读取到了子线程栈上的变量值 123。虽然栈是线程私有的,但同进程内其他线程并非完全不能访问。
二、先用 pthread_create 把线程模型跑出来
2.1 创建第一个线程
要创建线程,需要使用第三方库 pthread ,函数为 pthread_create。

cpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>
void *threadrun(void *args)
{
std::string name = (const char *)args;
while (true)
{
std::cout << "我是新线程:name " << name << std::endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadrun, (void *)"thread-1");
while (1)
{
std::cout << "我是主线程..." << std::endl;
sleep(1);
}
return 0;
}

新线程执行 threadrun,主线程继续向下运行 while 函数。threadrun 就是新线程的入口函数,编译出来是一组虚拟地址表示的代码和数据;main 函数执行时是另一组虚拟地址表示的代码和数据。主线程和新线程将不同虚拟地址作为入口,天然执行各自代码的一部分,自然将虚拟地址进行划分。
2.2 编译时链接 pthread
注意,线程函数是第三方库,不属于系统调用,编译连接时需要加 -lpthread:
makefile
test_thread:TestThread.cc
g++ -o $@ $^ -lpthread
2.3 用 ps -aL 看 LWP
怎么证明是两个线程?用 ps 查看发现只有一个进程:
bash
ps ajx | head -1 && ps ajx | grep test_thread | grep -v grep

kill 杀掉进程,两个线程都退出了------证明信号是线程共享的。

只想看到线程的话:
bash
# -a 表示所有
ps -aL

两个线程的 PID 是一样的------它们都属于同一个进程。LWP(Light Weight Process)表示轻量级进程,编号不一样,证明进程中存在两个轻量级进程。编号小的是主线程,且 LWP 和 PID 数值是相等的。

CPU 调度的时候看 PID 还是 LWP?答案是 LWP,因为 Linux 中只有轻量级进程。
2.4 三个实验结论
1. 时间片共享 :创建新线程时,系统层多了个轻量级进程,时间片在创建时要等分给不同的线程。假设进程只有 10ms 时间片,创建两个线程则各自 5ms。时间片也是资源共享的。 若时间片不共享,每创建一个线程就再分 10ms,恶意程序不断创建线程获取时间片,就会导致程序出错。
2. 异常崩溃:让新线程进行除 0 操作------任何一个线程崩溃都会导致整个进程崩溃。
3. 打印混杂:多个线程向显示器打印消息会混杂在一起。两个线程访问的是同一个终端文件,显示器本质上也是共享资源;没有加保护时,多个线程同时写入就可能出现原子性问题,可以通过加锁解决。

三、Linux 为什么还需要 pthread 库
3.1 Linux 的轻量级进程模型
Linux 系统不存在真正意义上的线程。它所谓的概念,是用轻量级进程模拟的。OS 中只有轻量级进程,所谓模拟线程是我们的说法。Linux 系统里只存在轻量级进程。
所以 Linux 操作系统只会提供创建轻量级进程的系统调用,如 vfork(创建的子进程和父进程共享地址空间):

cpp
int g_val = 100;
int main(void)
{
pid_t pid = vfork();
if (pid < 0)
{
perror("vfork");
return 1;
}
else if (pid == 0)
{
// child
printf("[子进程] pid=%d, 父pid=%d, 修改前 g_val=%d\n",
getpid(), getppid(), g_val);
g_val = 888; // 修改全局变量,父子共享内存
printf("[子进程] 修改后 g_val=%d\n", g_val);
_exit(0); // vfork 子进程必须 _exit,不能 return
}
else
{
// father
printf("[父进程] pid=%d, 子pid=%d, g_val=%d\n",
getpid(), pid, g_val);
}
return 0;
}

另一个系统调用 clone 也是创建共享地址空间的轻量级进程:

总结:Linux 操作系统只存在轻量级进程,创建子进程的模式就两种------1. 把整个进程拷贝 2. 创建轻量级进程(地址空间不变,拷贝 PCB)。操作系统不会提供真正创建线程的接口。
3.2 pthread 库的作用
作为用户,只认线程。在 Linux 中使用线程,用户和 OS 出现了鸿沟------两者不匹配。Linux 设计者为了解决这个问题,给用户提供了一层软件层:pthread 库。其工作是将创建轻量级进程封装起来,给用户提供一批创建线程的接口。
Linux 的线程实现在用户层,我们称之为用户级线程 。pthread 库被称为原生线程库------出生在 Linux 系统上的线程库,与 Linux 系统强绑定,但属于用户层。
3.3 C++11 的 thread
C++11 引入了多线程 std::thread。在老版 GCC 中不写 -pthread 会报错,因为在 Linux 下 thread 本质是封装 pthread 库的。
新版系统(高版本 glibc + libstdc++)不写
-pthread也能跑,但这是"隐式间接依赖",不是正确写法。根本原因:libstdc++.so本身写了NEeded依赖libpthread.so,动态链接器加载libstdc++.so时自动顺带加载libpthread.so。
cpp
#include <thread>
void hello()
{
while (true)
{
std::cout << "新线程:hello world,pid:" << getpid() << std::endl;
sleep(1);
}
}
int main()
{
std::thread t(hello);
while (true)
{
std::cout << "我是主线程...,pid:" << getpid() << std::endl;
sleep(1);
}
t.join();
return 0;
}
在 Windows 下 C++11 是通过封装 Windows 创建线程的接口。语言的跨平台和可移植性一般怎么实现?------所有平台全部实现一份,通过条件编译形成库。
四、pthread 线程控制:创建、等待、退出、取消与分离
4.1 创建线程
cpp
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void*), void *arg);
| 参数 | 说明 |
|---|---|
thread |
返回线程 ID,输出型参数 |
attr |
设置线程属性,NULL 表示使用默认属性 |
start_routine |
函数地址(函数指针),线程启动后要执行的函数入口 |
arg |
传给线程启动函数的参数 |
返回值:成功返回 0;失败返回错误码。
错误检查:pthreads 函数出错时不会设置全局变量 errno,而是将错误代码通过返回值返回。建议通过返回值判定,因为读取返回值比读取线程内的 errno 变量开销更小。
4.2 线程等待
线程创建好后,新线程要被主线程等待,否则会出现类似僵尸进程的问题------内存泄漏。
cpp
int pthread_join(pthread_t thread, void **retval);
thread:创建线程时对应的 ID 值retval:线程退出时的返回值

cpp
void *routine(void *args)
{
std::string name = static_cast<const char *>(args);
int cnt = 5;
while (cnt--)
{
std::cout << "我是一个新线程:我的名字是: " << name << std::endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, routine, (void *)"thread-1");
(void)n;
pthread_join(tid, nullptr); // 不关心退出结果
return 0;
}

4.3 线程 ID 与 pthread_self
线程的 ID 和 LWP 一样吗?打印出来发现不是底层的 LWP:
cpp
void showtid(pthread_t &tid)
{
printf("tid:%ld\n", tid); // 数字很大
}

以十六进制打印:
cpp
printf("tid:0x%lx\n", tid);

怎么证明打印的 tid 就是新线程的 ID?用 pthread_self------返回调用线程自己的 ID:

cpp
std::string FormatId(pthread_t &tid)
{
char id[64];
snprintf(id, sizeof(id), "0x%lx", tid);
return id;
}
void *routine(void *args)
{
std::string name = static_cast<const char *>(args);
pthread_t tid = pthread_self();
int cnt = 5;
while (cnt--)
{
std::cout << "新线程: " << name
<< " 我的ID是:" << FormatId(tid) << std::endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, routine, (void *)"thread-1");
showtid(tid);
int cnt = 5;
while (cnt--)
{
std::cout << "main线程 ID是:" << FormatId(pthread_self()) << std::endl;
sleep(1);
}
pthread_join(tid, nullptr);
return 0;
}


主线程 join 时等待的就是新线程。main 函数也是一个线程,也可以获得自己的 tid。
上述代码中,主线程和新线程同时调用
FormatId函数------有两个执行流同时进入该函数内部,这就是函数被重入 。该函数是可重入函数,因为函数内部只有局部变量。
4.4 线程返回值与传参传对象
线程的返回值是 void*,最后会被 pthread_join 拿到。主线程需要用 void** 来获取返回值:
cpp
pthread_join(tid, &ret);
std::cout << "ret is:" << (long long int)ret << std::endl;
为什么
pthread_join没有处理异常信号?因为等待的目标线程如果异常了,整个进程都退出了,包括 main 线程。只有线程正常 return / pthread_exit(),pthread_join才会把void*返回值填到ret里面。 异常信号是进程要处理的话题。
传参和返回值可以是任意类型,包括自定义对象:
cpp
class Task {
public:
Task(int a, int b) : _a(a), _b(b) {}
int Execute() { return _a + _b; }
private:
int _a, _b;
};
class Result {
public:
Result(int result) : _result(result) {}
int GetResult() { return _result; }
private:
int _result;
};
void *routine(void *args)
{
Task *t = static_cast<Task *>(args);
sleep(1);
Result *res = new Result(t->Execute());
sleep(1);
return res;
}
int main()
{
pthread_t tid;
Task *t = new Task(10, 20); // 堆上开辟,防止主线程提前退出
pthread_create(&tid, nullptr, routine, t);
Result *ret = nullptr;
pthread_join(tid, (void **)&ret);
std::cout << "新线程结束,运行结果: " << ret->GetResult() << std::endl;
delete t;
return 0;
}
4.5 线程终止的三种方式
方式 1:return
线程的入口函数进行 return 就是线程终止。注意:不能调用 exit------exit 是终止进程的,会导致所有线程退出!

方式 2:pthread_exit
谁调用终止谁,返回值也是 void*:

cpp
void *routine(void *args)
{
Task *t = static_cast<Task *>(args);
Result *res = new Result(t->Execute());
pthread_exit(res);
std::cout << "你看到我了吗" << std::endl; // 不会执行
}

方式 3:pthread_cancel
取消线程,常规做法是主线程取消新线程:

cpp
pthread_create(&tid, nullptr, routine, t);
sleep(3); // 保证新线程已启动
pthread_cancel(tid);
std::cout << "新线程被取消了" << std::endl;
void *ret = nullptr;
pthread_join(tid, &ret);
std::cout << "新线程结束,运行结果: " << (long long)ret << std::endl;

线程被取消时,退出结果是 -1 (
PTHREAD_CANCELED)。pthread_join拿到的返回值就是线程退出时设定的返回值。

4.6 线程分离
如果主线程不想等待新线程,可以让新线程结束时自己释放------设置为分离状态(detach)。

可以是主线程分离新线程,也可以是新线程自己分离:
cpp
// 主线程分离新线程
pthread_detach(tid);
// 新线程自己分离
pthread_detach(pthread_self());
cpp
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, routine, (void *)"thread-1");
pthread_detach(tid);
std::cout << "新线程被分离了" << std::endl;
int n = pthread_join(tid, nullptr);
if (n != 0)
std::cout << "pthread_join error: " << strerror(n) << std::endl;
return 0;
}

分离的线程依旧在进程的地址空间中,进程的资源依旧可以访问。只是主线程不等待它了。 如果线程被设置为分离状态,不需要 join,join 会失败。
总结
到这里,线程已经从概念走到了真正可操作的接口:
text
pthread_create
↓
线程开始执行
↓
return / pthread_exit / pthread_cancel
↓
pthread_join 回收
或
pthread_detach 分离
同时要记住:pthread 是用户层接口,真正被 Linux 内核调度的仍然是 LWP。下一篇就继续追 pthread_t、NPTL、线程栈和 clone 的底层关系。
资源分享
【Linux】线程到底是什么?从轻量级进程、虚拟地址到页表与 MMU,一次理清线程底层模型