【Linux】二十六.线程篇三《Linux多线程编程:线程控制、线程ID以及进程地址空间分布、线程局部存储__thread以及clone系统调用》---详解

在上节内容中我们学写了线程的创建相关的知识点,今天我们紧接着学习后面的知识


一.线程的控制

1.线程终止

如果需要只终⽌某个线程⽽不终⽌整个进程,可以有三种⽅法:

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

  2. 线程可以调⽤pthread_ exit终⽌⾃⼰。

  3. ⼀个线程可以调⽤pthread_ cancel终⽌同⼀进程中的另⼀个线程。

pthread_exit函数

cpp 复制代码
功能:线程终⽌
原型:
void pthread_exit(void *value_ptr);
参数:
value_ptr:value_ptr不要指向⼀个局部变量。
返回值:
⽆返回值,跟进程⼀样,线程结束的时候⽆法返回到它的调⽤者(⾃⾝)

接口

需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是⽤malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

a.return返回

代码如下:

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

using namespace std;

void* thread_func(void* args) {
    // 线程要做的事情:打印5次
    for (int i = 0; i < 5; i++) {
        cout << "子线程: " << i << endl;
        sleep(1);
    }
    
    // 线程结束,返回一个数字(假装是结果)
    return (void*)100;
}

int main() {
    pthread_t tid;
    
    // 创建线程
    pthread_create(&tid, nullptr, thread_func, nullptr);
    
    cout << "主线程: 等待子线程结束..." << endl;
    
    // 等待子线程结束,接收返回值
    void* ret = nullptr;
    pthread_join(tid, &ret);
    
    cout << "主线程: 子线程结束了,返回了 " << (long long)ret << endl;
    
    return 0;
}

这个程序是创建了一个子线程,子线程里面从0打印到4,每次打印完就睡1秒,一共打印5次,然后return 100,子线程结束。主线程在子线程运行的时候用pthread_join等着,等子线程结束了拿到100,打印出来。

thread_func是子线程的函数,参数是void* args,但是这个参数没用到,所以创建线程的时候传了nullptr。函数里面有一个for循环,int i=0; i<5; i++,每次打印i,然后sleep(1)。循环结束就return (void*)100。

main里面定义pthread_t tid,然后pthread_create,第一个参数是&tid,第二个nullptr,第三个thread_func,第四个nullptr。然后cout打印"主线程: 等待子线程结束..."。然后void* ret = nullptr,pthread_join(tid, &ret),主线程就卡在这等子线程结束。等到了之后打印"主线程: 子线程返回了 ",然后把ret转成long long打印出来,就是100。最后return 0。

这个代码的关键就是pthread_create创建线程,pthread_join等待线程结束并接收返回值,return让线程结束并返回数据。


b.用 pthread_exit 退出

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

using namespace std;

void* thread_func(void* args) {
    for (int i = 0; i < 3; i++) {
        cout << "子线程: " << i << endl;
        sleep(1);
    }
    
    // 用 pthread_exit 退出,返回 13
    pthread_exit((void*)13);
    
    // 这行不会执行
    cout << "子线程: 我退出了" << endl;
}

int main() {
    pthread_t tid;
    
    pthread_create(&tid, nullptr, thread_func, nullptr);
    
    cout << "主线程: 等待..." << endl;
    
    void* ret = nullptr;
    pthread_join(tid, &ret);
    
    cout << "主线程: 子线程返回了 " << (long long)ret << endl;
    
    return 0;
}

代码如下:

这个程序和代码一差不多,区别是子线程只打印0、1、2,一共三次,然后调用pthread_exit((void*)13)结束线程,返回13。pthread_exit后面的cout << "子线程: 我退出了" << endl不会执行,因为线程已经结束了。

thread_func里面for循环只到3,i<3,所以打印0、1、2。循环结束后pthread_exit((void*)13),线程立刻结束,后面的代码不跑了。

main里面和代码一一样,pthread_create创建线程,pthread_join等待线程结束,打印返回值。只是打印的时候ret是13。


退出方式的区别

cpp 复制代码
// 方式1:return(推荐)
return (void*)100;

// 方式2:pthread_exit(在函数中间提前退出时用)
pthread_exit((void*)13);

// 两者效果一样,都能让线程结束并返回数据

pthread_exit和return的区别就是return只能写在函数最后,pthread_exit可以写在函数中间任意位置,写了之后线程立刻结束,后面的都不执行。两个都能返回数据给主线程。


c.pthread_cancel函数

cpp 复制代码
功能:取消⼀个执⾏中的线程
原型:
int pthread_cancel(pthread_t thread);
参数:
thread:线程ID
返回值:成功返回0;失败返回错误码

代码如下:

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

using namespace std;

void* threadRoutine(void* args)
{
    cout << "子线程开始执行" << endl;
    sleep(1);
    
    while(true)
    {
        cout << "子线程: " << (char*)args << " running..." << endl;
        sleep(1);
    }
    cout << "子线程退出" << endl;
    return nullptr;
}

int main()
{
    pthread_t tid;
    
    cout << "1. 主线程创建子线程" << endl;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"thread 1");
    
    sleep(2);
    cout << "2. 主线程调用pthread_cancel取消子线程" << endl;
    pthread_cancel(tid);
    
    int cnt = 0;
    while(true)
    {
        cout << "3. 主线程 running..." << endl;
        sleep(1);
        cnt++;
        if(cnt == 3) break;
    }
    
    void* ret = nullptr;
    cout << "4. 主线程调用pthread_join等待子线程结束" << endl;
    pthread_join(tid, &ret);
    
    cout << "5. 主线程等待结束,子线程退出码: " << (long long)ret << endl;
    
    return 0;
}

pthread_cancel是取消线程的函数,传入线程ID就能让那个线程终止。这个程序里主线程创建子线程后sleep了2秒,让子线程先跑起来打印点东西,然后调用pthread_cancel(tid)取消子线程。子线程收到取消请求后立刻结束,while死循环被打断,后面的代码都不执行了。

pthread_cancel只是发送取消请求,不会等线程真正结束。所以主线程调用完pthread_cancel后继续执行自己的事情,打印三次running。后面调用pthread_join的时候才会真正等待子线程结束并回收资源。子线程被取消后pthread_join会立刻返回,退出码是-1,也就是PTHREAD_CANCELED。

被取消的线程退出码固定是-1,这个值定义在pthread.h里,叫PTHREAD_CANCELED。pthread_join的第二个参数会收到这个值,主线程就能知道子线程是被取消的而不是正常结束的。

子线程的死循环本身不会结束,如果没有pthread_cancel它会一直跑。主线程调用pthread_cancel后子线程就被强制终止了。所以运行结果里子线程只打印了开始信息和两次running,被取消后就不再打印了。主线程最后拿到-1,说明线程是被取消的。


2.线程等待

为什么需要线程等待?

  • 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
  • 创建新的线程不会复⽤刚才退出线程的地址空间。
cpp 复制代码
功能:等待线程结束
原型
int pthread_join(pthread_t thread, void **value_ptr);
参数:
thread:线程ID
value_ptr:它指向⼀个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码

接口

调⽤该函数的线程将挂起等待,直到id为thread的线程终⽌。thread线程以不同的⽅法终⽌,通过
pthread_join得到的终⽌状态是不同的,总结如下:

  • 如果thread线程通过return返回,value_ ptr所指向的单元⾥存放的是thread线程函数的返回值。
  • 如果thread线程被别的线程调⽤pthread_ cancel异常终掉,value_ ptr所指向的单元⾥存放的是常数PTHREAD_ CANCELED。
  • 如果thread线程是⾃⼰调⽤pthread_exit终⽌的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
  • 如果对thread线程的终⽌状态不感兴趣,可以传NULL给value_ ptr参数

线程在Linux内核里实际上是一个轻量级进程,内核为每个线程维护一个task_struct结构体,也就是通常说的PCB。这个结构体里包含了线程的所有信息,包括状态、优先级、寄存器上下文、栈指针等等。线程退出的时候,入口函数返回或者调用pthread_exit,退出码会被保存到task_struct里的某个字段中。

pthread库在用户态也维护了一个数据结构,和内核的task_struct对应,里面记录了线程的状态、线程ID、退出码等信息。线程退出时,退出码先被写入用户态的这个结构里,然后通过系统调用通知内核线程结束了。内核收到通知后,把线程的task_struct标记为退出状态,但不会立刻释放,因为主线程可能还要通过pthread_join获取退出码。

主线程调用pthread_join的时候,通过线程ID找到对应的用户态数据结构,检查线程是否已经结束。如果还没结束就阻塞等待,内核把主线程挂起。等子线程真正退出后,内核唤醒主线程,pthread_join从用户态数据结构里读出退出码,写到用户传入的void**指向的内存位置。然后pthread_join通知内核可以彻底回收这个线程的资源了,内核释放task_struct和线程的栈空间,完成回收。

所以pthread_join做的事就是:从PCB里读退出码,然后释放PCB和线程占用的所有资源。如果线程结束但不调用pthread_join,PCB和栈空间就一直不会被释放,造成资源泄漏。这种泄漏不会让进程立刻崩溃,但会慢慢累积,进程占用的内核资源越来越多,最终可能无法创建新线程或者系统变慢。这就是必须用pthread_join等待的原因。

代码如下:

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

using namespace std;

void* threadRoutine(void* args)
{
    int* p = new int(100);
    return (void*)p;    // 返回值存入PCB
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, nullptr);
    
    void* ret = nullptr;
    pthread_join(tid, &ret);    // 从PCB里读退出码写入ret
    
    int* result = (int*)ret;
    cout << *result << endl;
    delete result;
    
    return 0;
}

过程如下:

子线程在堆上new了一个int,存100,把地址返回给主线程。主线程用pthread_join等着拿这个地址,然后打印100,最后delete释放。

关键点在于堆内存不会因为线程结束就销毁,所以子线程返回堆地址是安全的。如果返回栈地址,子线程一结束栈就没了,主线程再访问就会出问题。

pthread_join的第二个参数是void**,传的是ret的地址,这样函数内部才能修改ret的值,把子线程返回的地址写进去。调用完pthread_join后,ret就指向了子线程的堆内存。

整个过程就是子线程造数据,主线程接数据,最后释放。


3. 分离线程

pthread_detach

用于创建线程,它是一个回调函数。如果线程创建成功,则会执行 start_routine,编译和链接时需要引入 pthread。

接口:

cpp 复制代码
int pthread_detach(pthread_t thread);

参数

thread是线程ID,就是要分离的那个线程。可以是其他线程的ID,也可以传自己的线程ID,pthread_self()返回的就是自己的ID。

默认情况下,新创建的线程是joinable的,线程退出后,必须由其他线程调用pthread_join来回收资源,否则线程占用的栈空间和内核描述符不会被释放,造成内存泄漏。线程泄漏和进程泄漏不一样,进程泄漏是变成僵尸进程占着PID,线程泄漏是占着栈空间和描述符,不会变僵尸但会慢慢耗尽系统资源,能创建的线程总数有限,泄漏多了后面就创建不了新线程了。

**如果不关心线程的返回值,pthread_join就是一种负担,**主线程要卡在那里等,可能影响程序整体的响应速度。这个时候可以把线程设置为分离状态detach,告诉系统这个线程退出时自动释放资源,不需要别人等它。这就是线程分离的本质。

**joinable和detach是冲突的,一个线程不能既是joinable又是detach。**默认创建出来的是joinable状态,调用了pthread_detach就变成detach状态,变不回去。所以新创建的线程默认是不分离的,必须显式调用pthread_detach才能变成分离状态。

就算线程被分离了,它也还是和其他线程共享同一块地址空间,照样能访问进程里的所有全局变量、堆内存、文件描述符。分离只是针对资源回收方式的设置,不影响线程访问共享资源的能力。一个分离的线程如果访问了已经被释放的内存,或者修改了全局变量,照样会影响其他线程。

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

using namespace std;

void* thread_func(void* args)
{
    pthread_detach(pthread_self());
    
    for (int i = 0; i < 3; i++)
    {
        cout << "子线程: " << i << endl;
        sleep(1);
    }
    
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, thread_func, nullptr);
    
    for (int i = 0; i < 5; i++)
    {
        cout << "主线程: " << i << endl;
        sleep(1);
    }
    
    return 0;
}

子线程函数里先调用pthread_detach(pthread_self())把自己分离,然后循环打印3次,每次睡1秒。

主线程创建子线程后直接进入循环打印5次,每次睡1秒,不等子线程。

主线程循环结束后直接return 0,程序退出。因为子线程已经分离了,所以主线程不用管子线程,子线程退出的时候系统会自动回收资源。


二. 线程ID及进程地址空间布局

1.线程ID和进程ID

在Linux中,目前的线程实现是Native POSIX Thread Library,简称NPTL。在这种实现下,线程又被称为轻量级进程,每一个用户态的线程在内核中都对应一个调度实体,拥有自己的进程描述符task_struct结构体。

多线程的进程又被称为线程组,线程组内的每一个线程在内核中都有一个task_struct与之对应。task_struct中的pid字段对应的是线程ID,不是进程ID。task_struct中的tgid字段是Thread Group ID,对应的是用户层面看到的进程ID。

用户态概念 系统调用 内核task_struct字段
线程ID syscall(SYS_gettid) pid
进程ID getpid() tgid

这里说的线程ID和pthread_t类型的线程ID不一样。pthread_t是pthread库自己维护的线程标识,而gettid返回的是内核层面的线程ID,是pid_t类型,用来在内核中唯一标识一个线程。

ps命令加上-L选项会显示LWP和NLWP两列。LWP是Light Weight Process,就是线程ID,也就是gettid系统调用的返回值。NLWP是线程组内线程的个数。

举例说明,一个进程里有三个线程:

  • 主线程:pid=28543,tgid=28543

  • 线程B:pid=28544,tgid=28543

  • 线程C:pid=28545,tgid=28543

它们的tgid都是28543,这就是用户态看到的进程ID。而pid各不相同,是内核用来区分各个线程的ID。Linux提供了gettid系统调用用来返回线程ID,但glibc没有封装这个系统调用,不能直接调用。如果确实需要获得内核线程ID,可以用syscall函数:

cpp 复制代码
#include <sys/syscall.h>
pid_t tid = syscall(SYS_gettid);

线程组内的第一个线程,在用户态被称为主线程,在内核中被称为group leader。内核在创建第一个线程时,会将tgid的值设置成第一个线程的pid,group_leader指针指向自身。所以线程组内主线程的pid等于tgid,也就是进程ID。

内核源码里是这样设置的:

cpp 复制代码
p->tgid = p->pid;
p->group_leader = p;

对于线程组里的其他线程,内核分配新的pid,但tgid设置为当前线程的tgid,也就是主线程的pid。group_leader指向主线程的task_struct。

cpp 复制代码
if (clone_flags & CLONE_THREAD) {
    p->tgid = current->tgid;
    p->group_leader = current->group_leader;
}

线程和进程不一样,进程有父进程的概念,父进程和子进程是层级关系。但线程组里面所有的线程都是对等关系,没有父子之分,只有主线程和普通线程的区别,它们共享同一个地址空间和资源。

补充:pthread_t和内核线程ID的区别

pthread_t是pthread库在用户态维护的线程标识符,pthread_self()返回的就是这个值,它是一个不透明的类型,可能是个结构体指针也可能是个整数,不同平台实现不一样,不能直接当作整数用。gettid返回的是内核层面的线程ID,是pid_t类型,是个整数,内核用它来调度和管理线程。两者没有直接关系,一个是用户态库的标识,一个是内核的标识。


2.线程ID及进程地址空间布局和对原生线程库的理解

  • pthread_ create函数会产⽣⼀个线程ID,存放在第⼀个参数指向的地址中该线程ID和前⾯说的线程ID不是⼀回事。
  • 前⾯讲的线程ID属于进程调度的范畴 。因为线程是轻量级进程,是操作系统调度器的最⼩单位,所以需要⼀个数值来唯⼀表⽰该线程
  • pthread_ create函数第⼀个参数指向⼀个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴线程库的后续操作,就是根据该线程ID来操作线程的。
  • 线程库NPTL提供了pthread_ self函数,可以获得线程⾃⾝的ID:
cpp 复制代码
pthread_t pthread_self(void);

pthread_t 到底是什么类型呢?

取决于实现。对于Linux⽬前实现的NPTL实现⽽⾔,pthread_t类型的线程ID,本质就是⼀个进程地址空间上的⼀个地址。

1.Linux线程的本质

Linux操作系统没有真正意义上的线程,而是用进程来模拟的,所以叫轻量级进程(LWP)。系统本身没有提供线程创建、终止、等待、分离这些系统调用接口,但是提供了clone系统调用来创建轻量级进程。为了更好用,系统在用户态封装了一个原生线程库pthread,这样用户就能用pthread_create这些函数来操作线程了。

系统通过PCB来调度管理线程,用户层通过pthread库来管理线程的属性数据。

2.线程相关的ID有两个。

一个是LWP,属于进程调度范畴。线程是轻量级进程,是操作系统调度的最小单位,内核需要一个数值来唯一标识它,这个数值就是LWP。ps命令加上-L选项看到的LWP列就是这个值,gettid系统调用返回的也是这个值。

另一个是pthread_t类型的线程ID,是pthread_create的第一个参数返回的。这个ID属于用户层范畴,是pthread库自己维护的标识,库的后续操作都靠这个ID来找到对应的线程。这个ID本质是一个地址,指向共享区中pthread库维护的某个结构体的起始位置。

两个ID是一一对应的关系,每个用户态线程在内核里都有一个LWP。

3.pthread库在内存中的布局

原生线程库libpthread.so在磁盘上是一个动态库文件,程序运行时被加载到内存,映射到共享区。这样所有线程都能看到这个库的代码和数据。

pthread库在共享区中为每个线程维护了一个管理块,类型是struct pthread,里面存放线程的所有属性数据,包括线程ID、状态、优先级、上下文、栈地址、线程局部存储等。这个结构体就是用户态线程的TCB。

pthread_t类型的线程ID,本质上就是struct pthread结构体在共享区中的起始地址。拿到这个地址就能在库中找到线程的所有属性数据。

4.线程栈

每个线程都必须有自己独立的栈空间,用来保存线程运行时的中间数据、局部变量、函数调用链。

主线程的栈用的是地址空间的栈区,叫主线程栈,不经过pthread库分配。

子线程的栈是pthread库在共享区或者堆上分配的,由库管理。

图片里画了共享区中多个struct pthread结构体和对应的线程栈,每个线程的TCB和栈都在共享区里,相邻排列。

5.pthread_create作用

pthread_create底层调用了clone系统调用。clone的参数包括入口函数地址、栈地址、标志位等。传入CLONE_THREAD标志,让新进程和父进程共享地址空间、文件描述符、信号处理器等,这样看起来就像线程了。

内核为这个新创建的轻量级进程分配task_struct结构体,分配LWP,加入调度队列。

pthread库在共享区为这个线程分配struct pthread结构体和线程栈,初始化属性数据。

最后把struct pthread的地址作为pthread_t类型的线程ID返回给用户。

6.内核和用户态的联动

内核里的task_struct对于每个线程都有一个,里面记录pid、tgid、状态、优先级等。用户层的pthread库结构体和内核的task_struct通过LWP关联起来,但两者是独立的,一个在用户态管理,一个在内核态管理。用户态代码不知道LWP的值,内核不知道pthread_t的值,它们通过系统调用和1:1的映射关系联动。

用户态和内核态通过系统调用传递数据,比如pthread_join调用时,用户态传入pthread_t,库根据这个地址找到对应的struct pthread,从里面拿到LWP,然后通过系统调用让内核等待这个LWP结束,获取退出状态写入struct pthread,再返回给用户。

7.用户线程和LWP的映射关系

Linux采用的是1:1模型,每个用户态线程对应内核中的一个LWP。其他系统有一对多或者多对多的模型,但Linux用1:1,调度简单,每个线程都是独立的调度实体。


实际上很多操作系统在设计线程时都是用户级线程和内核级线程配合使用的。用户级线程把相关的属性数据放在用户层管理,真正的调度还是得由内核级线程来处理。Linux采用的是1:1模型,一个用户线程对应一个内核线程。也有1:N的模型,用户层只有一个执行流,但内核层有多个执行流去跑这个任务。还有M:N模型,用户层M个线程对应内核层N个执行流。Linux选1:1是为了调度简单,每个线程都是独立的调度实体,互不干扰。

**用户级线程和内核级线程怎么关联?**其实就是用户级线程把要执行的代码交给内核级线程去跑。创建用户线程就是创建一个LWP,退出用户线程就是退出对应的LWP,再清理库里的数据。用户层的操作能和内核层对应上就行了。

**打个比方,**这就像公司里项目经理和一线员工的关系。项目经理接到客户需求,把任务拆解分配给下面的员工去干,员工负责具体执行,项目经理负责对接客户、汇报进度。客户不需要知道具体是哪个员工在干活,只需要和项目经理沟通就行。项目经理就是用户级线程,一线员工就是内核级线程,一一对应。客户就是应用程序,只需要调pthread_create、pthread_join这些接口就行,不需要知道底层LWP怎么创建的、栈怎么分配的。

Linux需要原生线程库的原因就是,Linux本身没提供真正的线程,只提供了轻量级进程和clone接口。用户直接操作LWP的话,要自己分配栈、设置clone标志、管理资源,太麻烦了。所以封装一个pthread库,提供简单接口,用户只管创建线程、等待线程、分离线程,底层的脏活累活库和内核去干。就像客户只管提需求,项目经理去安排谁干活、怎么干,客户不用关心具体执行细节。


三.线程封装

这个Thread.hpp封装了一个线程类,把pthread的C风格接口包装成C++的类,让用户用起来更方便。类里包了这些东西:线程ID、线程名字、是否分离、是否在运行、退出码、线程要执行的函数。用户创建一个Thread对象,传进去一个任务函数,调用Start就开始跑了,Stop停止,Join等待结束,Detach分离。

代码如下:

Main.cc

cpp 复制代码
#include "Thread.hpp"
#include <unistd.h>

using namespace ThreadModlue;

int main()
{
    Thread t([]{
        while(true)
        {
            std::cout << "我是一个新线程" << std::endl;
            sleep(1);
        }
    });

    t.Start();      
    t.Detach();
    sleep(5);
    t.Stop();      
    sleep(5);
    t.Join();       

    return 0;
}

这段代码创建了一个线程对象t,传进去一个lambda作为线程要执行的函数。然后调用Start启动线程,线程开始跑while循环每秒打印一次。接着Detach把线程分离,分离后主线程不再管它。主线程睡5秒,子线程在这5秒内一直在打印。然后Stop取消线程,线程停止。再睡5秒,最后Join等待线程结束。但因为线程已经被分离了,Join会打印"线程已经是分离状态,不能join"然后返回。


Thread.hpp

这个文件定义了一个Thread类,把pthread的C风格接口封装成C++类。jiexiruxai1

cpp 复制代码
#ifndef _THREAD_H_
#define _THREAD_H_

#include <iostream>
#include <string>
#include <pthread.h>
#include <cstdio>
#include <cstring>
#include <functional>

namespace ThreadModlue
{
    static uint32_t number = 1;

    class Thread
    {
        using func_t = std::function<void()>;

    private:
        void EnableDetach()
        {
            std::cout << "线程被分离了" << std::endl;
            _isdetach = true;
        }

        void EnableRunning()
        {
            _isrunning = true;
        }

        static void* Routine(void* args)
        {
            Thread* self = static_cast<Thread*>(args);
            self->EnableRunning();
            if (self->_isdetach)
                self->Detach();
            pthread_setname_np(self->_tid, self->_name.c_str());
            self->_func();
            return nullptr;
        }

    public:
        Thread(func_t func)
            : _tid(0),
              _isdetach(false),
              _isrunning(false),
              _res(nullptr),
              _func(func)
        {
            _name = "thread-" + std::to_string(number++);
        }

        void Detach()
        {
            if (_isdetach)
                return;
            if (_isrunning)
                pthread_detach(_tid);
            EnableDetach();
        }

        bool Start()
        {
            if (_isrunning)
                return false;
            int n = pthread_create(&_tid, nullptr, Routine, this);
            if (n != 0)
            {
                std::cerr << "create thread error: " << strerror(n) << std::endl;
                return false;
            }
            std::cout << _name << " create success" << std::endl;
            return true;
        }

        bool Stop()
        {
            if (!_isrunning)
                return false;
            int n = pthread_cancel(_tid);
            if (n != 0)
            {
                std::cerr << "cancel thread error: " << strerror(n) << std::endl;
                return false;
            }
            _isrunning = false;
            std::cout << _name << " stop" << std::endl;
            return true;
        }

        void Join()
        {
            if (_isdetach)
            {
                std::cout << "线程已经是分离状态,不能join" << std::endl;
                return;
            }
            int n = pthread_join(_tid, &_res);
            if (n != 0)
            {
                std::cerr << "join thread error: " << strerror(n) << std::endl;
            }
            else
            {
                std::cout << "join success" << std::endl;
            }
        }

        ~Thread() {}

    private:
        pthread_t _tid;
        std::string _name;
        bool _isdetach;
        bool _isrunning;
        void* _res;
        func_t _func;
    };
}

#endif

解析如下:

namespace

cpp 复制代码
namespace ThreadModlue
{
    
}

把Thread类放在ThreadModlue命名空间里,防止和其他代码的名字冲突。拼写错了应该是ThreadModule。

静态成员变量

cpp 复制代码
static uint32_t number = 1;

用来给线程起名字,每创建一个线程就加1,所以线程名字是thread-1、thread-2、thread-3这样排下去。这是个静态成员变量,所有Thread对象共享同一个number。

类型别名

cpp 复制代码
using func_t = std::function<void()>;

定义了任务函数的类型,是一个返回值void、没有参数的函数。用std::function包起来,可以接收lambda、函数指针、函数对象、bind表达式等等。

私有成员变量

cpp 复制代码
pthread_t _tid;      // 线程ID,pthread库用的
std::string _name;   // 线程名字,自己起的
bool _isdetach;      // 是否已经被分离
bool _isrunning;     // 线程是否在运行
void* _res;          // 线程退出码
func_t _func;        // 线程要执行的任务函数

私有成员函数

EnableDetach

cpp 复制代码
void EnableDetach()
{
    std::cout << "线程被分离了" << std::endl;
    _isdetach = true;
}

标记线程为分离状态,打印提示。

EnableRunning

cpp 复制代码
void EnableRunning()
{
    _isrunning = true;
}

标记线程为运行状态。

Routine

cpp 复制代码
static void* Routine(void* args)
{
    Thread* self = static_cast<Thread*>(args);
    self->EnableRunning();
    if (self->_isdetach)
        self->Detach();
    pthread_setname_np(self->_tid, self->_name.c_str());
    self->_func();
    return nullptr;
}

这是pthread_create要求的入口函数。必须是静态的,因为普通成员函数有隐藏的this指针,跟pthread要求的函数签名void*(*)(void*)对不上。

传进来的参数是Thread对象的指针,Start里面传的是this。Routine先转回Thread*,然后标记线程正在运行,检查如果已经分离了就调用Detach,设置线程名字,最后调用用户的任务函数_func()。任务函数执行完线程就结束了。

公有成员函数

构造函数

cpp 复制代码
Thread(func_t func)
    : _tid(0),
      _isdetach(false),
      _isrunning(false),
      _res(nullptr),
      _func(func)
{
    _name = "thread-" + std::to_string(number++);
}

接收一个任务函数,初始化所有成员变量,线程名字自动生成。_isdetach和_isrunning初始都是false。

Detach

cpp 复制代码
void Detach()
{
    if (_isdetach)
        return;
    if (_isrunning)
        pthread_detach(_tid);
    EnableDetach();
}

分离线程。如果线程已经在运行了,调用pthread_detach真正分离。如果线程还没运行,先标记_isdetach为true,等Routine里看到_isdetach是true的时候再调用Detach。已经分离过了就直接返回。

Start

cpp 复制代码
bool Start()
{
    if (_isrunning)
        return false;
    int n = pthread_create(&_tid, nullptr, Routine, this);
    if (n != 0)
    {
        std::cerr << "create thread error: " << strerror(n) << std::endl;
        return false;
    }
    std::cout << _name << " create success" << std::endl;
    return true;
}

启动线程。如果已经在运行就返回false。调用pthread_create创建线程,第四个参数传this,Routine拿到this就能操作当前对象。创建成功打印成功信息,失败打印错误信息。

Stop

cpp 复制代码
bool Stop()
{
    if (!_isrunning)
        return false;
    int n = pthread_cancel(_tid);
    if (n != 0)
    {
        std::cerr << "cancel thread error: " << strerror(n) << std::endl;
        return false;
    }
    _isrunning = false;
    std::cout << _name << " stop" << std::endl;
    return true;
}

停止线程。底层调pthread_cancel取消线程,成功标记_isrunning为false。线程不在运行就直接返回false。

Join

cpp 复制代码
void Join()
{
    if (_isdetach)
    {
        std::cout << "线程已经是分离状态,不能join" << std::endl;
        return;
    }
    int n = pthread_join(_tid, &_res);
    if (n != 0)
    {
        std::cerr << "join thread error: " << strerror(n) << std::endl;
    }
    else
    {
        std::cout << "join success" << std::endl;
    }
}

等待线程结束。如果线程已经分离了不能join,打印提示返回。否则调用pthread_join阻塞等待,线程结束的时候退出码存到_res里。成功打印"join success",失败打印错误信息。

析构函数

cpp 复制代码
~Thread() {}

整体思路:

创建Thread对象传任务函数。调用Start,pthread_create创建线程,入口是Routine。Routine拿到this指针,标记运行状态,检查分离标记,调用用户的任务函数。用户任务函数执行完线程结束。主线程可以调用Join等待结束拿退出码,或者调用Detach让线程自己回收。也可以调用Stop取消线程。


Makefile


运行结果:


四.知识补充:

虽然Linux将进程和线程统一到了task_struct结构体进行管理,但对待地址空间中的栈还是有些区别的。

进程栈

对于Linux进程或者说主线程来说,栈就是main函数的栈空间。在fork的时候,子进程复制了父进程的栈空间地址,通过写时拷贝技术实现共享,栈空间可以动态增长。如果栈的增长超出了ulimit设置的上限,就会触发段错误,操作系统发送段错误信号给进程。进程栈比较特殊,它是唯一一种可以访问未映射页面而不一定会触发段错误的情况,只有超出栈的扩充上限才会报段错误。

线程栈

对于主线程生成的子线程来说,栈不是动态增长的,而是事先固定下来的。线程栈一般是通过调用pthread库接口pthread_create创建线程时分配的,位置在文件映射区,也就是共享区。底层使用mmap系统调用分配,这在glibc的nptl/allocatestack.c中的allocate_stack函数里可以看到:

cpp 复制代码
mem = mmap(NULL, size, prot, MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);

这个调用中的size参数获取比较复杂,用户可以手动传入栈的大小,也可以使用默认值,一般是8MB。关键点在于,线程栈不能动态增长,一旦用尽就没了,这和fork出来的进程栈不一样。

在glibc中通过mmap得到栈空间之后,底层会调用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;
    newsp = regs->cx;              // 获取mmap得到的线程栈指针
    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);
}

注意:

子线程的栈本质上是进程地址空间中mmap出来的一块内存区域,原则上是线程私有的。但是同一个进程的所有线程在生成的时候,会浅拷贝创建者的task_struct的很多字段,所以如果愿意,其他线程也可以访问这块内存。这就意味着线程栈虽然设计上是私有的,但实际上在同一个进程中并不是绝对安全的,其他线程如果拿到了栈地址,照样可以读写,编程时一定要注意这个问题。


线程局部存储 __thread

多线程编程里,全局变量是所有线程共享的。一个线程改了,其他线程都能看到。有时候我们确实需要这样,比如多个线程一起统计总数。但有时候我们想要的是每个线程都有自己的独立变量,互不干扰。比如每个线程都要记录自己的错误码,自己的缓存数据,自己的随机数种子。如果用全局变量,线程之间会互相覆盖。如果用局部变量,函数之间传递又麻烦。线程局部存储就是解决这个问题的。

1.__thread

__thread 是 GCC 的一个关键字,用来声明线程局部变量。被它修饰的变量,每个线程都有一份独立的副本。一个线程改了自己的,其他线程不受影响。

cpp 复制代码
__thread int count = 1;

这行定义了一个全局变量,但每个线程都有自己的 count,各是各的。

3.代码如下:

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

__thread int count = 1;

void* thread1(void* args)
{
    while(true)
    {
        std::cout << "thread1: count = " << count << std::endl;
        count++;
        sleep(1);
    }
}

void* thread2(void* args)
{
    while(true)
    {
        std::cout << "thread2: count = " << count << std::endl;
        sleep(1);
    }
}

int main()
{
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, thread1, nullptr);
    pthread_create(&t2, nullptr, thread2, nullptr);
    
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    return 0;
}

4.运行结果

thread1 的 count 一直往上加,thread2 的 count 始终是1。两个线程完全不影响。

打印两个线程里 count 的地址,可能看到相同的数字。但那是虚拟地址,不同线程的虚拟地址映射到不同的物理内存。所以地址看着一样,实际存的地方不同,数据自然也不同。

5.普通全局变量和 __thread 的区别

普通全局变量所有线程共享一个,一个改了全部变。__thread 变量每个线程各有一份,各改各的。普通全局变量访问需要加锁,__thread 不需要,因为压根不共享。

6.使用限制

__thread 只能修饰简单的类型,比如int、char、指针这些。不能修饰复杂的C++对象,比如string、vector。如果需要复杂对象,可以用C++11的 thread_local

cpp 复制代码
thread_local std::string name = "hello";

7.底层原理

__thread 变量存在pthread库管理的线程局部存储区里,每个线程创建的时候分配一份,线程退出的时候释放。编译器通过当前线程的控制块找到对应的存储位置,所以同一个变量名在不同线程里访问的是不同的内存。

__thread 不是C++标准,是GCC自己的扩展,换编译器可能不支持。C++11的 thread_local 是标准的,推荐用那个。__thread 不能修饰局部变量,只能修饰全局变量或者静态变量。

线程局部存储就是让全局变量变成线程私有的。多线程编程里,该共享的用普通全局变量加锁,该私有的用 __thread,各干各的,清晰又高效。


clone系统调用

Linux创建轻量级进程的底层接口

1.背景

Linux没有真正意义上的线程,只有进程。线程是用进程模拟的,叫轻量级进程LWP。创建普通进程用fork,创建轻量级进程用clone。pthread_create底层调用的就是clone。

2.代码如下:

cpp 复制代码
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

#define STACK_SIZE (1024 * 1024)

static int child_func(void *arg)
{
    printf("Child process: PID = %d\n", getpid());
    return 0;
}

int main()
{
    char *stack = (char *)malloc(STACK_SIZE);
    if (stack == NULL) {
        perror("malloc");
        exit(EXIT_FAILURE);
    }

    pid_t pid = clone(child_func, stack + STACK_SIZE, CLONE_VM | SIGCHLD, NULL);
    if (pid == -1) {
        perror("clone");
        free(stack);
        exit(EXIT_FAILURE);
    }

    printf("Parent process: PID = %d, Child PID = %d\n", getpid(), pid);

    if (waitpid(pid, NULL, 0) == -1) {
        perror("waitpid");
        free(stack);
        exit(EXIT_FAILURE);
    }

    free(stack);
    return 0;
}

3.运行结果:

4.过程如下:

程序先分配了1MB的栈空间给子进程用,然后调用clone创建子进程。子进程执行child_func函数,打印自己的PID后返回0结束。父进程打印自己的PID和子进程的PID,然后调用waitpid等子进程结束,最后释放栈空间退出。

运行结果就是父进程先打印一行,子进程再打印一行,然后程序结束。

CLONE_VM让父子进程共享地址空间,SIGCHLD让子进程退出时通知父进程。所以这个子进程实际上已经很像线程了,因为它和父进程共享内存,只是有自己独立的栈。


5.flags参数说明

flags 作用
CLONE_VM 共享地址空间
CLONE_FS 共享文件系统
CLONE_FILES 共享文件描述符
CLONE_SIGHAND 共享信号处理器
CLONE_THREAD 加入同一个线程组
SIGCHLD 子进程退出时发信号给父进程

pthread_create创建线程时传的是CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD,除了栈和寄存器独立其他都共享。
clone是Linux创建轻量级进程的底层接口,通过flags精确控制共享哪些资源。pthread_create底层调的就是clone。理解了clone就理解了Linux线程的本质:线程就是共享了地址空间的进程。

相关推荐
PC2005-cloud8 小时前
博文已索引但不展示:一次 Bing SEO 问题排查记录
服务器·人工智能
沸速存储8 小时前
整机瓶颈判断思路,自上而下定位卡顿根源
运维·服务器·电脑·内容运营
维吉斯蔡8 小时前
【VS Code / Cursor】文件夹右键快捷打开与文件类型自动关联
开发语言·程序人生·学习方法
奈斯先生Vector9 小时前
告别工具碎片化:基于 Nano Banana 全模态 AI 聚合架构搭建“文本-图像-视频”自动化协同生产线
运维·数据库·人工智能·架构·自动化·aigc·音视频
乌萨奇也要立志学C++9 小时前
【Linux】网络基础 从零理解网络通信:局域网、跨网传输与 Socket 预备知识
linux·服务器·网络
DLYSB_10 小时前
AIOps 大模型实战:基于 LangChain 运维 Agent 的根因诊断与现场声光播报闭环
运维·langchain·报警灯
luj_176811 小时前
星火科技助力边远地区防病攻坚
c语言·开发语言·c++·经验分享·算法
always_TT11 小时前
【Python 日志记录:logging 模块入门】
开发语言·python·php
RisunJan11 小时前
Linux命令-slabtop(实时显示内核 slab 缓存信息)
linux