【Linux】多线程打印为什么会乱?pthread 创建、等待、退出、取消与分离全实战

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》C++

🐶学习方向: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;

线程被取消时,退出结果是 -1PTHREAD_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,一次理清线程底层模型

【Linux】信号到底什么时候被处理?sigaction、中断、用户态内核态与 SIGCHLD

【Linux】malloc 1GB 内存为什么没立刻占满?从缺页、COW 到 Cache/TLB,看懂线程为什么更轻

相关推荐
DYWorker00111 分钟前
基于RK3568的Linux驱动开发:实战——WDT
linux·驱动开发
小马同学-12 分钟前
四、虚拟化技术
运维·云计算
初願致夕霞13 分钟前
五种IO模型(详解非阻塞IO与多路转接)
linux·前端·网络·数据库·tcp/ip
luj_176813 分钟前
尾椎藏今生记忆?骨盆对应不确定性
开发语言·网络·c++·经验分享·算法
Lzg_na15 分钟前
WiFi吞吐量和实际文件传输时速率差异原因以及定位
运维·网络
技术猿禁19 分钟前
Linux运维开发
linux·运维·运维开发
民乐团扒谱机22 分钟前
【读论文】基于波分复用和时分复用的单纤高精度双向时间传递系统
运维·服务器·网络
断点之下25 分钟前
C++类和对象:六个默认成员函数详解
开发语言·c++
脚踏实地,坚持不懈!25 分钟前
Android ANR 内核底层全解析:从一次触摸到“应用无响应”
android·linux