一、什么是线程

线程(thread)是程序中的一条执行路线,更准确地说,它是一个进程内部的控制序列,是进程内部的一个执行分支。所有进程至少都包含一个执行线程,线程在进程内部运行,本质上是在进程的地址空间内执行。在 Linux 系统中,CPU 视角下的线程 PCB(进程控制块)比传统进程的 PCB 更加轻量化,线程通过进程的虚拟地址空间共享进程的大部分资源,将这些资源合理分配给不同的执行流,便形成了独立的线程执行流。

要深入理解线程,必须学习具体的操作系统的线程实现

二、Linux中线程的实现

2.1线程的实现

Linux线程在进程内部运行,本质上是在进程的地址空间内运行。我们之前学习的进程,内部只有一个线程,即内部只有一个执行分支。而今天在有了线程的概念后,一个进程中可能会同时存在一个或多个线程。多个线程+地址空间+页表+代码和数据=进程。而多个线程+地址空间+页表就是进程的内核数据结构。

那CPU需要对进程和线程做严格区分吗?不需要区分,因为线程属于操作系统调度的基本单位

在创建进程时,先要预先申请一大批的资源:内存、CPU、IO,然后把这些资源分配给线程。所以资源由进程申请,而线程获取进程所申请的这部分资源。所以在操作系统的视角下,进程是承担分配系统资源的基本实体

在Linux中,为了管理众多的进程,每个进程都有自己专属的PCB来管理对应的进程。而一个进程中可以有多个线程,这么多的线程肯定也需要被管理。所以,为了管理进程,就必须先描述,在组织!操作系统为了有效管理线程,引入了线程控制块TCB(Thread Ctrl Block),用于记录线程的标识、状态、资源信息及上下文数据,从而实现对线程的创建、调度、同步与销毁等管理。

在Windows操作系统中,为了管理线程,就单独为线程设计了TCB;而在Linux中,没有单独的 TCB 结构,而是用进程内核结构模拟实现线程的效果。线程和进程用同一个task_struct结构体来表示。线程就是进程内的多个执行流。

在 Linux系统中,内核没有为线程单独设计独立的 TCB 线程控制块,而是复用进程 PCB 进程控制块来实现线程,因此 Linux 下的线程本质就是轻量级进程,创建线程只能依靠系统调用生成轻量级进程来完成实现。

但是在用户层面,用户只认线程,而Linux只能提供创建轻量级进程的系统调用。所以Linux在操作系统和用户层之间新增了一层软件层,叫做pthread库。这个库存在的意义就是对上层用户层对象提供管理线程的接口和参数,对下层调用系统调用。所以这种叫做用户级线程库。这个库必须存在,否则进程就跑不起来。我们把这种库称为原生线程库

创建轻量级进程的系统调用clone如下:

clone()中的参数:

fn:是新创建的执行单元要运行的函数,子进程或线程从这个函数开始执行,不再从父进程的调用点继续运行

stack:是为子进程或线程手动分配的栈空间起始地址,必须由调用者自行提供,不能直接复用父进程栈

flags:是核心控制标志位,通过按位或组合多个常量,精准决定新执行单元与父进程共享虚拟内存、文件描述符、信号处理、文件系统信息等哪些资源,也能设置命名空间隔离、线程 ID 管理等行为

arg:是传递给 fn 函数的参数,作为新函数的入参使用;parent_tid:可选参数,用于存放父进程视角下看到的子线程 ID

tls:可选参数,设置子进程或线程的线程本地存储地址

child_tid:可选参数,用于存放子进程或线程自身的线程 ID,还能配合标志位实现退出时的清 0 通知。

返回值:成功则返回父进程中返回子进程的 PID,子进程中返回 0;失败则返回 -1,errno记录错误原因。

所以在CPU视角,没有进程,只有执行流。我们把执行流叫做轻量级进程。进程=一个或多个轻量级进程+其他资源。  

2.2线程的优缺点

2.2.1优点

1.创建和删除一个新线程的代价比新进程小很多

2.与进程之间的切换相比,线程之间的切换操作系统所要做的工作要少很多。

最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。

另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲 TLB(快表)会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件 cache。

3.线程占用的资源要比进程少

4.能充分利用多处理器的可并行数量

5.在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务

6.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现

7.I/O 密集型应用,为了提高性能,将 I/O 操作重叠。线程可以同时等待不同的 I/O 操作。

2.2.2缺点

1.性能损失

一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。

2.健壮性降低

编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。

3.缺乏访问控制

进程是访问控制的基本粒度,在一个线程中调用某些 OS 函数会对整个进程造成影响。

4.编程难度提高

编写与调试一个多线程程序比单线程程序困难得多。

2.3线程异常

单个线程如果出现除零、野指针问题导致线程崩溃,进程也会随着崩溃
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

2.4线程用途

合理的使用多线程,能提高 CPU 密集型程序的执行效率
合理的使用多线程,能提高 IO 密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

 2.进程和线程的关系

进程是资源分配的基本单位,线程是调度的基本单位

所有的线程都是共享虚拟地址空间的,本质就是共享有效的虚拟地址,也就是共享页表。也就是说,线程通过虚拟地址空间共享进程的大部分资源。线程共享进程数据,但也拥有自己的一部分 "私有" 数据,例如:线程 ID、一组寄存器(线程的上下文数据)、栈、errno、信号屏蔽字、调度优先级等。所以线程具有共享性,大部分资源共享。而进程是具有独立性的,大部分资源独占。

由于同一进程的多个线程共享同一地址空间,因此 Text Segment(代码段)、Data Segment(数据段)都是共享的。如果定义一个函数,在各线程中都可以调用;如果定义一个全局变量,在各线程中都可以访问到。除此之外,各线程还共享以下进程资源和环境:文件描述符表、每种信号的处理方式(SIG_IGN、SIG_DFL 或者自定义的信号处理函数)、当前工作目录、用户 id 和组 id。

进程和线程间的关系如下图所示:

三、Linux线程控制

3.1创建线程

在Linux中如何创建线程呢?

我们可以使用pthread_create函数来创建线程

使用示例:

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>

void *threadRun(void *args)
{
    while (1)
    {
        printf("new thread is running, pid: %d\n", getpid());
        // std::cout << "new thread is running" << std::endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, threadRun, NULL);

    while (1)
    {
        printf("main thread is running%d\n", getpid());
        // std::cout << "main thread is running" << std::endl;
        sleep(1);
    }
}

运行后你会发现,主线程和新线程打印的 pid 是完全一样的。原因是,在 Linux 中,同一进程下的所有线程共享同一个进程 ID(TGID线程组ID),getpid() 返回的就是这个 TGID,所以两个线程输出的 pid 相同。

我们可以使用ps -aL指令来查看当前Linux系统下所有的轻量级进程:

其中LWP(Light Weight Process)轻量级线程的LWP号与进程PID不相等,而LWP号与进程PID相等的是主线程。

但是,如果直接打印tid,会发现tid并不等于新线程的LWP,而是一个很大的数。原因是,pthread库对LWP进行了封装,不让用户直接看到LWP。而这个tid的值其实是一个地址!

在pthread库中还有另一个库函数用于获取线程自己的tid,这个函数是pthread_self()。

使用pthread_self()获取tid后,会发现新线程自己获取的tid和之前pthread_create获得的线程tid的值相同。也就是说主线程创建新线程时,能获取到新线程的tid,以方便后续对与新线程的管理。

创建线程时的传参问题

pthread_create函数的最后一个参数可以传递任意类型的参数,也就是说也可以传递类和结构体对象的地址!也可以给线程传递任务!

3.2创建多线程

刚刚演示的是创建一个单线程的过程,创建多线程的代码如下:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <cstdio>
#include <string>
#include <sys/types.h>

void *Routine(void *args)
{
    std::string name = static_cast<const char*>(args);
    while(true)
    {
        // 打印:线程名、线程ID、进程ID
        printf("我是新线程: %s, tid: 0x%lx, pid: %d\n", name.c_str(), pthread_self(), getpid());
        sleep(1);
    }
}

int main()
{
    // 创建 10 个线程
    const int num = 10;
    for(int i = 0; i < num; i++)
    {
        pthread_t tid;
        char* threadname = new char[64];
        snprintf(threadname, 64, "thread-%d", i+1);
        // 创建线程
        int n = pthread_create(&tid, nullptr, Routine, threadname);
        (void)n;
        pthread_detach(tid);
    }
    while(1)
    {
        sleep(1);
    }
    
    return 0;
}

上面这段代码中,多个执行流同时进入Routine函数,也就是Routine函数被重入了!如果在Routine函数的while循环中新定义一个函数,例如要打印一个hello world。那么新创建的这10个线程都会打印hello world。这也就证明了多个线程共享同一地址空间,多个线程的代码段、数据段都是共享的

一旦多线程中任意一个线程崩溃,会导致其他的线程直接崩溃。例如给8号线程安排一个除0错误,只要8号线程触发异常退出了,其他的线程也会随之退出,也就是整个进程会直接挂掉!

void *Routine(void *args)
{
    std::string name = static_cast<const char*>(args);
    while(true)
    {
        if(name == "thread-8"){
            int a = 10;
            a = a / 0; // 除零错误,触发进程崩溃
        }
        
        printf("我是新线程: %s, tid: 0x%lx, pid: %d\n", name.c_str(), pthread_self(), getpid());
        sleep(1);
    }
}

这就是为什么多线程的健壮性较低。

3.3线程终止

如果需要只终止某个线程而不终止整个进程,可以有三种方法:

1.从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。

2.线程可以调用pthread_exit终止自己。

3.一个线程可以调用pthread_cancel终止同一进程中的另一个线程。

pthread_exit函数:

retval:线程退出时的返回值,可以给pthread_join接收。调用后,线程立刻停止,后面代码不执行。区别于exit(),pthread_exit()是让一个线程终止,而exit()是直接终止整个进程。

pthread_cancel函数:

thread:传入对应线程的tid的值。注意:pthread_cancel()不能用于取消主线程!

但是一般不推荐使用pthread_cancel()函数来取消线程,使用这个函数来取消线程有一定的风险。线程的终止推荐使用前两种方式。

3.4线程等待

一个新创建的线程,必须被主线程等待。如果不进行主线程等待,会导致类似子进程那里的僵尸进程的问题;同时,主线程要获取新线程的执行结果,就必须要等待新线程。

那主线应该如何等待新线程呢?

使用pthread_join()来等待新线程:

作用:阻塞等待指定子线程结束,并回收线程资源,获取线程返回值。
pthread_join()的特点是,在调用这个函数后主线程会卡住,直到目标线程跑完;并且会自动释放线程栈、内核资源,防止线程内存泄漏。但是,一个线程只能被 pthread_join 等待一次!

pthread_join()的使用示例如下:

#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h> 

void *Routine(void *args)
{
    // 把传入的参数转成字符串
    std::string name = static_cast<const char*>(args);

    while(true)
    {
        std::cout << "new thread " << name << std::endl;
        sleep(5);   
        break;      
    }

    // 线程返回值(void* 类型),主线程可以通过 pthread_join 拿到
    return (void*)10;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, Routine, (void*)"thread-1"); 
    sleep(10);
    void *retval = nullptr;
    // 等待线程 tid 结束,并获取它的返回值到 retval
    int n = pthread_join(tid, &retval); 
    if(n == 0)
    {
        // 输出线程返回值:这里是 (void*)10,强转成 long long 输出
        std::cout << "join success: " << (long long)retval << std::endl;
    }
}

在使用pthread_join并获取新线程的退出信息时,我们会发现retval里保存的就是新线程的退出码。但是为什么没有退出信号呢?为什么没有进行异常分析呢?
原因是,新线程一旦出现异常,整个进程会直接退出,没有机会join成功!

所以新线程创建之后,我们不需要关心异常。而异常是由当前线程所属的进程的父进程所关心的。所以线程退出只关心正常情况。

由于这个返回值是void*类型,所以不一定只能返回一个数字,也可以是字符串、类和结构体对象!

class Res
{
public:
    int code;
    std::string name;
    std::string info;
};

void *Routine(void *args)
{
    std::string name = static_cast<const char*>(args);

    while(true)
    {
        std::cout << "new thread " << name << std::endl;
        sleep(5);
        break; /
    }

    Res *res = new Res();
    res->code = 10;
    res->name = name;
    res->info = "我这个线程已经结束了";

    // 把 Res 对象的地址作为线程返回值返回
    return (void*)res;
}

如果thread线程被别的线程调用pthread_cancel异常终掉,retval所指向的单元里存放的是常数PTHREAD_CANCELED。PTHREAD_CANCELED的值为-1。

3.5分离线程

默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统泄漏。

如果不关心线程的返回值,join 是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。这时我们就需要把目标被对等待的线程设置为分离状态

一个线程自己可以给自己分离,也可以被主线程分离。一旦分离之后,主线程就不再关心分离后的线程了!

那如何将线程进行分离呢?

调用pthread_detach():

传入对应线程的tid就能将对应线程变为分离状态。

主线程分离线程的示例:

#include <iostream>
#include <pthread.h>
#include <unistd.h>

void* thread_func(void* arg) {
    std::cout << "子线程:我开始运行啦\n";
    sleep(2);  
    std::cout << "子线程:我运行结束,系统会自动回收我的资源\n";
    return nullptr;
}

int main() {
    pthread_t tid;

    pthread_create(&tid, nullptr, thread_func, nullptr);
    pthread_detach(tid);

    std::cout << "主线程:已分离子线程,不需要调用 pthread_join\n";
    sleep(3);
    std::cout << "主线程:程序退出\n";

    return 0;
}

线程自己分离自己的示例:

#include <iostream>
#include <pthread.h>
#include <unistd.h>

void* thread_func(void* arg) {
    std::cout << "子线程:我开始运行啦\n";
    sleep(2);  
    std::cout << "子线程:我运行结束,系统自动回收资源\n";
    return nullptr;
}

int main() {
    pthread_t tid;

    pthread_create(&tid, nullptr, thread_func, nullptr);
    pthread_detach(tid);

    std::cout << "主线程:已分离子线程,不需要 join\n";
    sleep(3);  
    std::cout << "主线程:程序退出\n";

    return 0;
}

一旦线程要被设置为分离,主线程不能提前退出,甚至主线程本身就是一个死循环!

线程的分离只是为了让主线程不再阻塞等待已经处于分离状态的线程,只是设置线程的一种状态。但是线程的特性不会因为这个分离状态而发生改变,例如线程崩溃会直接影响整个进程的特性不会因为它被设置为分离状态而变化!

四、线程ID及进程地址空间布局

4.1线程ID

一个库是存储在磁盘中的文件。如果要调用一个库,首先要将库文件加载到内存中,如何再将这个库映射到进程的虚拟地址空间的共享区,在编译链接时做链接时重定位,然后在程序加载阶段完成符号解析与运行时重定位,使进程可以正常访问库中的函数与数据。

而pthread库本身也是一个库,要被映射到当前进程的虚拟地址空间以支持线程控制!

我们前面所看到的线程ID本质上是一个地址。线程是可以有多个的,而这些线程都需要被管理。线程要被管理,就必须先描述,再组织!所以就必须要有一个描述线程的结构体struct TCB{}。但是,在第二部分不是讲过,在Linux内核中没有为线程设置专门的结构体来管理,而是复用进程 PCB 进程控制块来实现线程吗?为什么在这里又说必须要有描述线程的结构体呢?

其实,描述线程的结构体并不在Linux的内核中,而是在pthread库当中维护的!

上图所示的就是pthread库映射到进程虚拟地址空间的mmap区域。而线程的TCB就包括下面的三个部分:

当我们每创建一个线程时,就会创建这一份TCB,用于管理新创建的线程。为了让上层用户能够快速找到新创建的线程的结构体,Linux将每次因为创建新线程而创建的一份新的TCB的起始地址,称为tid!所以tid的本质就是在库当中维护所有线程相关属性的起始地址

也就是说在一个库中,我们可以将某种对象以数据结构的形式管理起来!

但是,线程在执行时,其本质仍是依托进程相关的结构体完成任务的调度和执行;而 TCB主要负责保存轻量级进程(线程)运行结束后的退出信息及相关属性。

线程退出时,内核层面只会释放进程空间里对应的轻量级进程资源,同时把线程退出相关信息写回线程库,可线程库中用来保存线程各类属性的数据结构依旧没有释放。所以在线程退出之后,必须调用pthread_join来完成线程资源的回收工作!

4.2线程局部存储

有些变量只允许线程自己使用,每个线程都有独立的副本,互不干扰,这就是线程局部存储(TLS)。

简单来说,全局变量是所有线程共享的,而线程局部变量是每个线程私有的。一个线程修改自己的局部变量,完全不会影响其他线程,不需要加锁,也不会出现线程安全问题。

主线程、子线程都可以拥有自己独立的线程局部变量,而且只在当前线程内可见、可修改,不同线程的同名局部变量,是完全独立的存储空间。线程退出时,对应的线程局部存储会自动释放。

在 pthread 中,我们用线程特定数据(TSD)来实现线程局部存储,其核心就是让同一个变量,在不同线程里有不同的值。

线程局部存储示例:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>
using namespace std;

// 获取内核轻量级线程ID(LWP ID)
pid_t GetTid()
{
    return syscall(SYS_gettid);
}

// 线程局部存储:每个线程都有独立的 id 副本
__thread pid_t id = 0;

void *routine(void *args)
{
    // 每个线程都给自己的局部 id 赋值
    id = GetTid();
    string name = static_cast<const char*>(args);

    while (true)
    {
        printf("[%s] new thread lwp: %d, &id: %p\n", name.c_str(), id, &id);
        sleep(1);
    }

    return nullptr;
}

int main()
{
    // 主线程也给自己的局部 id 赋值
    id = GetTid();
    printf("[main] main thread lwp: %d, &id: %p\n", id, &id);

    pthread_t tid1, tid2;
    pthread_create(&tid1, nullptr, routine, (void*)"thread-1");
    pthread_create(&tid2, nullptr, routine, (void*)"thread-2");

    // 主线程也循环打印自己的 id,观察与子线程的区别
    while (true)
    {
        printf("[main] main thread lwp: %d, &id: %p\n", id, &id);
        sleep(1);
    }

    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    return 0;
}

运行结果如下:

__thread 是 GCC 提供的线程局部存储(TLS)扩展关键字,它会告诉编译器不要把这个变量放到 .data 段,而是放到线程局部存储区(TLS),给每个线程单独分配一份副本。

总结:线程局部存储只能用来局部存储内置类型,常见的是整形。 线程局部存储可以让不同的线程用同样的变量名,访问不同的内存块,各自访问各自的局部存储!

4.3线程栈

在前面第二部分提到过,线程除了共享进程资源以外,还要有自己的“私有”部分,而栈空间就是其中的一部分。主线程的栈属于进程地址空间里默认的栈空间,而新建子线程的栈是由线程库单独维护管理的栈空间。

五、线程封装

C++也有多线程:

#include <iostream>
#include <unistd.h>
#include <thread>

void routine(int cnt)
{
    while (cnt)
    {
        std::cout << "new thread : " << cnt << std::endl;
        sleep(1);
        cnt--;
    }

    return 0;
}

int main()
{
    std::thread t(routine, 10);

    while (true)
    {
        std::cout << "main thread" << std::endl;
        sleep(1);
    }
    t.join();

    return 0;
}

当我们对上述代码的头文件与相关语法做出适配修改后,将其放到 VS 环境中运行依然可以正常执行,这一现象充分说明了:C++11 标准库提供的线程接口具备良好的跨平台性。

C++11 之后的 std::thread 等多线程接口,本质是对各个操作系统原生线程库的跨平台封装。在 Linux 平台下,它底层是对 pthread 线程库的封装;在 Windows 平台下,它底层则是对 Windows 原生线程 API(如 CreateThread)的封装。

C++ 把所有平台对应的原生线程代码进行了统一封装,对外提供一套标准、通用、一致的线程接口。这样一来,凡是支持 C++ 标准的平台,都可以使用同一套多线程代码,不需要根据平台修改逻辑,真正实现了多线程的跨平台性。

为什么所有的语言追求跨平台性呢?

跨平台性能大幅降低开发成本、扩大用户覆盖、丰富生态,所以主流语言都会主动设计和维护跨平台能力,这是市场和需求驱动的结果!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐