《从零入门Linux系统篇(五十一):线程篇·四——pthread线程库详解:从线程创建到终止与分离》

上一篇,我们把线程的基本概念,以及它和进程之间的那层关系,里里外外捋了一遍。今天,正式进入Linux多线程编程的实战环节。

这次我们要拿下的,是pthread线程库提供的那套核心控制接口,线程怎么创建、怎么等待、怎么退出、怎么取消,还有线程分离。把这些接口一个个摸透,线程从出生到消亡的完整生命周期,以及背后那套系统机制,也就跟着浮出水面了。

目录

[一、认识 pthread------Linux线程的用户层接口](#一、认识 pthread——Linux线程的用户层接口)

[1.1 为什么需要 pthread 线程库](#1.1 为什么需要 pthread 线程库)

[1.2 从系统调用到pthread------多线程的层层封装](#1.2 从系统调用到pthread——多线程的层层封装)

[1.2.1 从底层实现到用户接口------完整调用链路](#1.2.1 从底层实现到用户接口——完整调用链路)

[1.2.2 C++11等高级语言如何实现跨平台线程](#1.2.2 C++11等高级语言如何实现跨平台线程)

二、pthread线程控制------创建、等待与获取线程ID

[2.1 线程创建与线程等待](#2.1 线程创建与线程等待)

[2.1.1 pthread_create:创建一个新线程](#2.1.1 pthread_create:创建一个新线程)

[2.1.2 pthread_join:等待线程结束并回收资源](#2.1.2 pthread_join:等待线程结束并回收资源)

[2.1.3 pthread_self:获取当前线程ID](#2.1.3 pthread_self:获取当前线程ID)

[2.1.4 用Demo理解线程创建与等待](#2.1.4 用Demo理解线程创建与等待)

[2.1.5 让线程真正执行一个任务](#2.1.5 让线程真正执行一个任务)

[2.2 线程终止与线程取消](#2.2 线程终止与线程取消)

[2.2.1 线程有哪些终止方式](#2.2.1 线程有哪些终止方式)

[2.2.2 pthread_cancel:如何取消一个线程](#2.2.2 pthread_cancel:如何取消一个线程)

[2.3 线程分离:不再等待线程退出](#2.3 线程分离:不再等待线程退出)

[2.3.1 为什么需要线程分离](#2.3.1 为什么需要线程分离)

[2.3.2 pthread_detach:设置线程分离状态](#2.3.2 pthread_detach:设置线程分离状态)

[2.4 几个容易忽略的线程问题](#2.4 几个容易忽略的线程问题)

[2.4.1 新线程能不能继续创建线程](#2.4.1 新线程能不能继续创建线程)

[2.4.2 新线程能不能取消主线程](#2.4.2 新线程能不能取消主线程)

[2.5 线程终止与分离------完整Demo验证](#2.5 线程终止与分离——完整Demo验证)

[2.5.1 pthread_detach线程分离Demo](#2.5.1 pthread_detach线程分离Demo)

[2.5.2 线程终止Demo](#2.5.2 线程终止Demo)


一、认识 pthread------Linux线程的用户层接口

1.1 为什么需要 pthread 线程库

在操作系统层面,Linux压根没有传统意义上那种独立、纯粹的"线程"概念。内核眼里只有轻量级进程(LWP,Light Weight Process),真正干活的,是clone、vfork这类系统调用,靠它们创建出共享地址空间的进程。可对用户来说,谁管你底层是进程还是LWP?开发者只关心"线程"这个抽象概念,关心怎么创建、怎么同步、怎么退出。需求在用户层,实现却在内核层,中间隔着一道鸿沟。

为了把这道沟填上,Linux掏出了pthread库,也就是POSIX线程库,江湖人称"原生线程库"。它干的事很纯粹:**把底层那些轻量级进程的系统调用封装起来,向上层暴露一套标准的线程创建与控制接口。**内核负责实现,pthread负责翻译,用户只管调用。

1.2 从系统调用到pthread------多线程的层层封装

Linux 的线程实现,归根结底是搭在用户层的。用户不需要操心底下LWP那套细节,整套结构,是一层套一层垒起来的。

1.2.1 从底层实现到用户接口------完整调用链路
  • 用户层:不同语言各自封装了不同的线程库。比如C++11的并发支持库,让你写多线程不用碰pthread的原生接口。

  • 原生线程库层(pthread):对底层接口再做一层封装,把系统调用包装成标准、好用的线程控制接口。

  • 操作系统层(OS):内核提供clone等系统调用,创建出与父进程共享地址空间的轻量级进程。这一层,才是真正动手干活的地方。

从内核的clone,到pthread的封装,再到各语言线程库的二次包装,一层叠一层,越往上越好用,越往下越接近真相。用户站在最顶端,看到的只有"线程"两个字,至于底下是怎么折腾的,全被封装挡得严严实实。

1.2.2 C++11等高级语言如何实现跨平台线程

像C++11里的std::thread,为什么能跨平台到处跑?秘密就藏在条件编译里。它自己并不直接跟操作系统打交道,而是根据不同平台,做一层二次封装:

  • Linux环境下:std::thread本质上就是对pthread库的进一步包装。
  • Windows环境下:它则转去封装Windows系统原生的创建线程接口。

一套代码,两种底层。你写的是std::thread,编译器在背后替你选了对应的那套实现。

看个例子:

cpp 复制代码
// C++11 的并发支持库
void hello() {
    while (true)
        std::cout << "新线程: hello world" << std::endl;
}

int main() {
    std::thread t(hello);
    while (true)
        std::cout << "我是主线程..." << ", pid: " << getpid() << std::endl;
    t.join();
    return 0;
}

主线程和新线程各跑各的,谁也不碍谁。std::thread把pthread那一堆原生接口全挡在了后面,你只管建线程、等线程,平台差异一概不用操心。这就是封装的威力,底下的脏活累活,交给库去扛。

二、pthread线程控制------创建、等待与获取线程ID

2.1 线程创建与线程等待

pthread这一套函数,名字几乎清一色以pthread_打头,一眼扫过去,就知道它们是同一个家族的。要用它们,得先引入头文件<pthread.h>。

编译链接的时候,别忘了带上-lpthread选项,否则链接器找不到这些函数的实现,直接报错。

2.1.1 pthread_create:创建一个新线程

函数原型:

cpp 复制代码
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void *), void *arg);

参数逐个拆:

  • thread:输出型参数,用来带回创建成功的线程ID,也就是tid。注意,这个tid是pthread库自己维护的概念,别直接跟内核里的LWP混为一谈。一个在用户层,一个在内核层,层次不同。
  • attr:线程属性。平时直接传nullptr,用默认属性就行,省事。
  • start_routine:函数指针,新线程要跑的入口函数。
  • arg:传给start_routine的参数。

返回值:

成功返回0;失败返回错误码,而且此时*thread里的内容是未定义的,别去碰。

这里有个地方容易搞混,得说清楚:

main函数一结束,代表主线程结束,通常也代表整个进程结束。而新线程的入口函数跑完,只代表当前这条线程运行结束,进程还在不在,得看别的线程。另外,pthread函数的报错方式,跟传统函数不太一样。传统函数往往是成功返回0、失败返回-1,再顺手给全局变量errno赋个值。但 pthreads函数不设errno,它把错误码直接通过返回值交给你。

当然,pthreads也提供了线程内的errno变量,好让那些依赖errno的代码能正常跑。但判断 pthreads 函数的错误,还是建议看返回值,读返回值,比读线程内的errno开销更小,也更直接。

2.1.2 pthread_join:等待线程结束并回收资源

新线程创建出来之后,必须有人等它。主线程也好,其他线程也罢,总得有个"收尸"的。没人等,它退出了就会变成类似僵尸进程的东西,资源收不回来,内存跟着漏。

函数原型:

cpp 复制代码
int pthread_join(pthread_t thread, void **retval);

参数和作用:

  • thread:要等待的目标线程ID,也就是pthread_create拿到的那一个。
  • retval:输出型参数,用来接收目标线程退出时的返回值。

为什么 join 的参数里,没有异常相关的字段?

因为等待的目标线程一旦发生异常,整个进程都会跟着崩溃退出,主线程也跑不掉。这种情况下,再想在join里捕获异常,已经没有任何意义了。join这套接口,设计时针对的就是线程正常跑完的场景。异常信号那是进程层面的事,轮不到join来操心。

调用pthread_join的线程会挂起等待,直到ID为thread的那条线程终止。目标线程以不同的方式结束,join拿到的终止状态也不一样:

  • 如果线程通过return返回,retval指向的单元里,放的是线程函数的返回值。
  • 如果线程被其他线程调用pthread_cancel异常终止,retval指向的单元里,放的是常量PTHREAD_CANCELED。
  • 如果线程自己调用pthread_exit终止,retval指向的单元里,放的是传给pthread_exit的参数。
  • 如果对目标线程的终止状态压根不感兴趣,retval直接传NULL就行。
2.1.3 pthread_self:获取当前线程ID

函数原型:

cpp 复制代码
pthread_t pthread_self(void);

作用很简单:返回调用线程自己的线程ID,也就是tid。

Tips:关于线程ID的几个误区

很多人对线程ID有误解。我们打印一个出来看看,它其实不是LWP。

打印出来的tid,是一个pthread_t类型的变量,代表调用pthread_self的那条线程的"ID"。怎么理解这个"ID"?它是pthread库给每个线程定义的进程内唯一标识,是pthread库自己维护的。

由于每个进程都有自己独立的内存空间,这个"ID"的作用域是进程级的,不是系统级,内核根本不认识它。

pthread库当然也是靠内核提供的系统调用(比如clone)来创建线程的。内核会给每个线程分配一个系统全局唯一的ID,用来唯一标识这个线程。那个,才是LWP。

LWP 是什么?

LWP拿到的才是真正的线程ID 。而pthread_self返回的那个数,实际上是一个地址,虚拟地址空间上的一个地址。通过这个地址,可以找到关于这条线程的基本信息,包括线程ID、线程栈、寄存器等等。

在ps -aL里看到的线程ID,有一个和进程ID相同,那条就是主线程。主线程的栈,就在虚拟地址空间的栈区上。其他线程的栈呢?它们待在共享区,也就是堆和栈之间那块地方。原因很简单:pthread 系列函数都是pthread库提供的,而pthread库本身就在共享区。所以,除了主线程之外,其他线程的栈,全都落在共享区里。这块内容现在先提一嘴,具体细节,后面再展开讲。

2.1.4 用Demo理解线程创建与等待
cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

void* thread_run(void* arg) {
    const char* name = (const char*)arg;
    for (int i = 0; i < 3; i++) {
        std::cout << name << " running, tid: " << pthread_self() << std::endl;
        sleep(1);
    }
    return (void*)100;
}

int main() {
    pthread_t tid;
    pthread_create(&tid, nullptr, thread_run, (void*)"thread 1");

    void* ret = nullptr;
    pthread_join(tid, &ret);

    std::cout << "thread exit code: " << (long long)ret << std::endl;
    return 0;
}

这段代码把创建和等待串成了一条完整的线:pthread_create把新线程拉起来,thread_run在里面跑三轮,每轮打印自己的名字和tid,然后睡一秒;跑完返回100。主线程这边,pthread_join一直等着,等到了就把返回值取出来,打印收工。

给**线程传的参数和返回值,可以是任意类型,既然任意类型都行,那自然也可以是一个对象。**指针一递一收,什么都能塞进去。

2.1.5 让线程真正执行一个任务

先看代码,再说几个关键点。

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <string>
#include <vector>
#include <functional>

using Task = std::function<void(void)>;

class TaskManager {
private:
    std::vector<Task> Taskv;
public:
    TaskManager() {};
    ~TaskManager() {};

    void Addtask(const std::function<void(void)>& task) {
        Taskv.push_back(task);
    }

    Task Gettask(int i) {
        return Taskv[i];
    }
};

void *Routine(void *args) {
    Task* task = (Task*)args;
    (*task)();
    delete task;
    return (void *)100;
}

void func_1() { std::cout << "线程 [" << pthread_self() << "] 正在执行:网络任务" << std::endl; }
void func_2() { std::cout << "线程 [" << pthread_self() << "] 正在执行:数据库任务" << std::endl; }
void func_3() { std::cout << "线程 [" << pthread_self() << "] 正在执行:系统任务" << std::endl; }
void func_4() { std::cout << "线程 [" << pthread_self() << "] 正在执行:重要任务" << std::endl; }
void func_5() { std::cout << "线程 [" << pthread_self() << "] 正在执行:关键任务" << std::endl; }

int main() {
    TaskManager taskmag;
    taskmag.Addtask(func_1);
    taskmag.Addtask(func_2);
    taskmag.Addtask(func_3);
    taskmag.Addtask(func_4);
    taskmag.Addtask(func_5);

    std::vector<pthread_t> pvtion = {0};
    for (int i = 0; i < 5; i++) {
        Task* task = new Task(taskmag.Gettask(i));
        int n = pthread_create(&pvtion[i], NULL, Routine, task);
        if (n != 0)
            std::cerr << "pthread_create error" << std::endl;
    }

    for (int i = 0; i < 5; i++) {
        void *ret = 0;
        pthread_join(pvtion[i], &ret);
        std::cout << (long)ret << std::endl;
    }

    return 0;
}

这段代码的设计思路很清晰:用std::function<void(void)>把任务抽象出来,TaskManager负责攒任务,主线程一口气拉起5条线程,每条线程从任务列表里领一个任务去干。Routine是线程入口,把Task从void*里还原出来,调一下,然后delete掉,动态分配的对象,谁领的谁负责回收。干完统一 pthread_join,收尸等结果。

2.2 线程终止与线程取消
2.2.1 线程有哪些终止方式

如果只想干掉某一条线程,又不想把整个进程拖下水,有三种办法:

  • 入口函数return:线程入口函数跑完,返回了,这条线程就结束了。注意,千万别在线程里用 exit(),那是终止整个进程的家伙,一按下去,所有线程全得陪葬。
  • 调用pthread_exit:终止调用它的那条线程本身。想退的是谁,就谁退,不影响别人。
  • 被其他线程取消:靠pthread_cancel出手,把目标线程叫停。

pthread_exit的函数原型:

cpp 复制代码
#include <pthread.h>

void pthread_exit(void *retval);

retval是线程退出的返回值,跟return retval; 一个意思,会被pthread_join收走。

这里有个坑必须拎出来说清楚:pthread_exit或者return返回的那个指针,它指向的内存,必须是全局的,或者是用malloc分配的,绝不能是在线程函数栈上分配的。 为什么?因为等别的线程拿到这个返回指针的时候,线程函数早就退出了,栈都销毁了。你给出去的是一个悬空指针,指向一块已经不属于你的内存。用了它,就是踩雷。

2.2.2 pthread_cancel:如何取消一个线程

函数原型先摆出来:

cpp 复制代码
#include <pthread.h>

int pthread_cancel(pthread_t thread);

参数和细节,拆开看:

  • **thread:**要取消的目标线程ID。指哪打哪,目标就是它。
  • **取消前提:**动手之前,得确保目标线程已经启动、正在跑。还没跑起来就喊取消,等于对着空气挥拳,没意义。
  • **返回值:**被取消的线程如果后来被pthread_join收了尸,它带回来的退出结果就是PTHREAD_CANCELED,本质上就是(void*)-1。
2.3 线程分离:不再等待线程退出
2.3.1 为什么需要线程分离

线程默认是可等待的,也就是joinable。主线程得对它调用pthread_join,把它那点残留下来的资源收走。不join?那就等着资源泄漏。

可有时候,主线程压根不关心新线程的返回值,也不打算等它。它只是想让新线程自己跑,跑完自己收拾干净,悄悄退场。这时候,就得把线程设成分离状态,也就是detached。

分离之后,线程依然待在进程的地址空间里,进程的资源它照样共享。唯一的区别是:主线程不用再join它了,也不允许join它。它退出了,资源自动释放,主线程该干嘛干嘛,两不相欠。

2.3.2 pthread_detach:设置线程分离状态

函数原型:

cpp 复制代码
#include <pthread.h>
int pthread_detach(pthread_t thread);

这个函数,主线程能调,新线程自己也能调。主线程调pthread_detach(tid),等于把某个新线程"推开",从此不再等它。新线程在内部调pthread_detach(pthread_self()),则是自己把自己分离出去,和主线程之间的等待关系一刀两断。

但有一条得记住:线程一旦被分离,就再也join不了了。 你要是再对它调pthread_join,函数直接报错返回,一点情面不留。

2.4 几个容易忽略的线程问题
2.4.1 新线程能不能继续创建线程

可以。在POSIX线程模型里,所有线程,不管是主线程还是新创建的子线程,在权限和地位上都是平等的。谁都可以创建新线程,只要它具备执行条件,调pthread_create就行。线程生线程,天经地义。

2.4.2 新线程能不能取消主线程

技术接口层面可以,但逻辑上极不推荐。

  • **技术可行性:**任何线程,只要拿到了主线程的线程ID,比如主线程把自己的pthread_self()当参数传给子线程,就能对主线程调pthread_cancel。
  • **实际影响:**主线程被取消之后,它会退出运行。但只要进程里还有别的子线程没退出、没分离,整个进程就不会立刻终止。得等所有线程都退出了,进程才真正结束。这种做法,极容易把资源回收的逻辑搅成一锅粥,正常的生命周期管理全被它破坏。能干,不代表该干。
2.5 线程终止与分离------完整Demo验证
2.5.1 pthread_detach线程分离Demo
cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <cstring>

void* thread_detach_demo(void* arg) {
    pthread_detach(pthread_self()); // 自我分离
    std::cout << "detached thread running..." << std::endl;
    sleep(1);
    return nullptr;
}

int main() {
    pthread_t tid;
    pthread_create(&tid, nullptr, thread_detach_demo, nullptr);

    sleep(2); // 确保子线程运行完成

    // 尝试 join 已分离的线程
    int ret = pthread_join(tid, nullptr);
    if (ret != 0) {
        std::cout << "pthread_join failed: " << strerror(ret) << std::endl;
    }

    return 0;
}

这段代码演示的是:子线程一跑起来,第一件事就是把自己分离出去,pthread_detach(pthread_self()),干净利落。分离之后,它和主线程之间的等待关系就断了。主线程睡够两秒,试着去join它,结果碰了一鼻子灰,pthread_join直接返回错误码。strerror一翻译,错误原因明明白白地打出来。这就是分离线程的规矩:一旦分离,概不join。

2.5.2 线程终止Demo
cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <cstring>

void* thread_exit_demo(void* arg) {
    std::cout << "new thread running..." << std::endl;
    sleep(1);
    pthread_exit((void*)200); // 终止自身并返回 200
}

int main() {
    pthread_t tid;
    pthread_create(&tid, nullptr, thread_exit_demo, nullptr);

    void* ret = nullptr;
    pthread_join(tid, &ret);
    std::cout << "thread exit code: " << (long long)ret << std::endl;

    return 0;
}

这个例子,展示的是pthread_exit的用法。子线程跑了一会儿,睡了一秒,然后调pthread_exit((void*)200),主动结束自己,顺手把200这个值当作"遗言"带出去。主线程这边,pthread_join一直候着,等它退场,把返回值取出来,转成long long打印。200稳稳地出现在屏幕上,线程的退出,干净利落。


如果这个系列对你有帮助,别忘了点个赞、点个收藏、点个关注。你的每一次反馈,都是我继续硬核输出的最大动力。

相关推荐
YYRAN_ZZU1 小时前
GMSL开发加解串配置介绍
linux·嵌入式硬件
带金箍的至尊宝1 小时前
系统架构设计师笔记 03:CPU 结构、Cache 与总线怎么考
笔记·系统架构
墨家前任巨子1 小时前
定时关机脚本
运维
云计算练习生1 小时前
什么是上下文切换?进程切换时 CPU 到底在保存什么
linux·服务器·操作系统·进程切换
用户0510122572961 小时前
RGA(二)——RK3588 的核拓扑与能力差异
linux·嵌入式
ESDWAN1 小时前
企业组网后网络频繁掉线,常见故障排查指南
运维·网络·架构
用户0510122572961 小时前
RGA(一)——基础知识
linux·嵌入式
shada1 小时前
CMake指定 MSVC 工具集与 Windows SDK
c++·windows
qetfw1 小时前
Debian 综合服务实训:Apache2、Samba、LDAP、vsftpd 与 OpenVPN
linux·服务器·debian