【Linux】多线程

目录​​​​​​​

Linux线程概念

1. 什么是线程

2. 重新定义线程和进程

3. 重讲地址空间

4. 线程的优点

5. 线程的缺点 

6. 线程异常 

7. 线程用途 

Linux进程VS线程 

1. 进程和线程

2. 进程的多个线程共享

3. 线程为什么进程要更加轻量化?

Linux线程控制

1. POSIX线程库

2. 创建线程

3. 线程等待 

4. 线程终止

5. 重谈线程的参数和返回值

6. C++11多线程vs原生线程库

7. 创建多个线程 

8. 线程分离

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


Linux线程概念

1. 什么是线程

  • 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是 “ 一个进程内部的控制序列 ”
  • 一切进程至少都有一个执行线程
  • 线程在进程内部运行,本质是在进程地址空间内运行。
  • 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。
  • 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

一般在Linux教材里面线程是这样定义的:

线程是在进程内部运行的一个执行分支,线程的执行粒度,要比进程更加细。

也就是一个进程内可能存在多个线程。所以进程和线程的比例关系是进程:线程 = 1:n

在OS中存在这么多的线程,那么OS要不要管理线程呢?如何管理呢?—— 先描述,再组织。 

因此我们就可以得出一个推论线程也应该要有线程控制块TCB。我们window下的多线程就是这样子做的。但是操作系统管理进程已经非常复杂,同样的方式管理线程会更复杂。

所以Linux下的多线程并没有像上面说的那样通过创建数据结构,然后通过管理数据结构从而达到管理线程的目的。那我们Linux下的多线程是怎么做的呢?

Linux管理线程采用的是复用进程数据结构和管理算法。        

我们知道创建一个进程,我们需要为它创建一些列的数据结构,例如:PCB(进程控制块)、mm_struct(进程地址空间)、页表和file_struct等

【Linux】多线程_第1张图片那如果我们在创建进程时,只创建task_struct,将那个创建出来的进程的task_struct和父进程的task_struct共享虚拟地址空间和页表,并将父进程的资源(代码+数据),划分为若干份,让每个task_struct使用会是怎么样的呢?

 我们来看下面这个图:【Linux】多线程_第2张图片

CPU此时看到的PCB是<=我们之前讲的PCB的概念的,CPU只有调度执行流的概念,在CPU看来一个PCB就是一个需要被调度的执行流。(如果进程只有一个线程,线程 = 执行流 = 进程,如果进程有多个线程,线程 = 执行流 < 进程)

这就是我们Linux下的线程,Linux中并没有像windows下为线程专门设计TCP,而是使用进程PCB来模拟线程。 

Linux管理线程的方法比Windows的方法好在哪里呢?

  • 不用维护复杂的进程和线程的关系,不用单独为线程实现管理算法,直接使用进程的一套相关的方法,OS只需要关注在线程间的资源分配上就可以了。

2. 重新定义线程和进程

什么叫线程?

我们认为,线程就是操作系统调度的基本单位!!

我们上面说线程是在进程内部运行的一个执行分支,这里的内部是什么意思呢?那什么又叫做一个执行分支呢?

这里的内部指的是线程是在进程的虚拟地址空间中运行的。执行分支指的是CPU调度的时候只看PCB,每一个PCB曾经被指派过指向方法和数据,CPU是可以直接调度的。

什么叫进程?

我们之前认为的进程:进程 = 内核数据结构(task struct) + 代码和数据

【Linux】多线程_第3张图片

了解了Linux下的线程之后,我们又该如何理解我们之前讲的进程呢?

学习了线程后,我们把下面用红色方框圈起来的内容,我们将这个整体叫做进程!!

我们从内核视角来看进程就是承担分配系统资源的基本实体!!

【Linux】多线程_第4张图片

之前我们讲的进程,内部只有一个执行流。学习了线程之后,我们重新定义的进程,内部可以具有多个执行流。创建进程的 "成本非常高",成本:时间+空间,创建一个进程要使用的资源是非常多的。

小结:我们从内核视角来看进程就是承担分配系统资源的基本实体!! 而线程就是CPU调度的基本单位,承担进程资源的一部分的基本实体,进程划分资源给线程。总得来说 Linux下的线程就是轻量级进程。  

3. 重讲地址空间

前面我们说线程就是CPU调度的基本单位,承担进程资源的一部分的基本实体,进程划分资源给线程,那么如何理解资源分配各个线层呢?下面我们通过重谈地址空间来解决这个问题。

我们先来解决下面这个问题——虚拟地址是如何转换到物理地址的? ? ? 32位虚拟地址为例
虚拟地址是多少位的? 32位

我们先来解释下面几个概念:

  • 页目录:页目录是虚拟内存到物理内存映射的索引表,它包含了1024个页目录表项(PDE)。每个PDE指向一个二级页表。
  • 二级页表:二级页表是实际映射表,包含了1024个页表表项(PTE)。每个PTE指向物理内存中的一个页框。
  • 物理内存:物理内存是实际存储数据的内存空间,以页框为单位进行管理,每个页框为4kb。 

虚拟地址的32位地址划分成三部分:10+10+12,他们从全0到全1进行穷举,并且转化成10进制数

虚拟地址是如何转换到物理地址的过程:

  1. 根据虚拟地址的高位部分,在页目录中找到相应的页目录表项。虚拟地址前10位转化成10进制数就是页目录的下标,通过前10位虚拟地址找到页目录对应的表项。
  2. 根据页目录表项中的地址,在二级页表中查找相应的二级页表页目录表项存放的是二级页表的地址,我们就可以找到对应的二级目录。
  3. 根据二级页表表项中的地址,在物理内存中找到对应的页框。中间10位虚拟地址转换成10进制数就是二级页表的下标,二级目录的表项存放的是物理内存当中的页框起始地址,找到对应的页框的地址。
  4. 最终,通过页框中的偏移量,确定虚拟地址在物理内存中的具体位置。虚拟地址的最后12位是我们要访问物理内存在页框中的偏移量!!也就是说最后我们通过虚拟地址的最后12位地址+加上页框地址的起始地址就是=我们要访问的物理地址!!

访问任何变量都是:起始地址+类型 = 起始地址 + 偏移量(X86的特点)

举个例子:
【Linux】多线程_第5张图片

一个整型有4个字节,每个字节一个地址,我们&a只拿到了最低位地址,然后根据整型是4个字节,我们往后取4个地址就可以取到整个整型了。

也就是说,我们c语言中变量取到的地址都是他众多字节当中的最低位地址,然后CPU可以根据变量的类型,通过起始地址加偏移量的方式就可以知道每次我们要读取多少字节,加载多少字节。

类也一样,编译完之后就没有类的概念了。也就是说类也是内置类型的集合。

我们来看下面这个图加深理解:

【Linux】多线程_第6张图片

缺页中断:当软件试图访问已映射在虚拟地址空间中,但是并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断,称为缺页中断。(中间10位得到的地址找不到对应的二级页表表项,或者二级页表表项存放的页框的起始地址根本就没有建立映射关系。)

我们CPU当中还有CR2寄存器,当我们缺页中断时,CR2寄存器可以保存最后一次出现缺页中断的全32位线性地址。在缺页中断发生时,CPU会通过读取CR2来获取导致缺页中断的线性地址,以便进行错误处理和恢复操作。因此,CR2寄存器对于CPU的错误处理和内存管理具有重要的意义。

4. 线程的优点

  • 创建一个新线程的代价要比创建一个新进程小得多
  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
  • 线程占用的资源要比进程少很多
  • 能充分利用多处理器的可并行数量
  • 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
  • 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
  • I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

5. 线程的缺点 

性能损失

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

健壮性降低

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

缺乏访问控制

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

编程难度提高

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

6. 线程异常 

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

7. 线程用途 

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

Linux进程VS线程 

1. 进程和线程

进程是资源分配的基本单位

线程是调度的基本单位

线程共享进程数据,但也拥有自己的一部分数据:

  • 线程ID
  • 一组寄存器(也就是线程的上下文,独立的上下文保证线程是被独立的调度的,保护CPU调度时存放在寄存器中的临时数据)
  • 栈(保护线程运行时所形成的临时数据,独立的栈结构保证线程调度的过程中不会出现线程错乱的问题)
  • errno
  • 信号屏蔽字
  • 调度优先级

2. 进程的多个线程共享

同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程
中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表
  • 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id

进程和线程的关系如下图:【Linux】多线程_第7张图片 

3. 线程为什么进程要更加轻量化?

  • 创建和释放更加轻量化(生死)
  • 切换更加轻量化 (运行)  
  1. 资源消耗:线程是进程内的一部分,因此线程的创建、切换和销毁等操作相对于进程更加轻量级。在操作系统中,创建新的进程需要分配独立的地址空间和虚拟地址空间,并且建立众多的数据表来维护其代码段、堆栈段和数据段,开销较大。而线程则共享进程的资源,所以线程的创建、切换和销毁等操作对系统资源的消耗较小。
  2. 并发性:线程可以共享进程的资源,使得多个线程之间可以直接通信和协作,而无需通过操作系统进行复杂的切换和通信操作。这种并发性使得线程在处理大量任务时更加高效。

【Linux】多线程_第8张图片CPU的cache存放的是CPU刚用过或循环使用的一部分数据,如果CPU需要再次使用该部分数据时可从Cache中直接调用,这样就避免了重复存取数据。对于进程和线程,CPU的cache对它们的执行效率有重要影响。线程的切换不需要重新cache数据,大大提高了效率

Linux线程控制

1. POSIX线程库

  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
  • 要使用这些函数库,要通过引入头文
  • 链接这些线程函数库时要使用编译器命令的“-lpthread”选项

2. 创建线程

创建线程函数原型如下:

【Linux】多线程_第9张图片

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg);

功能:创建一个新的线程 

参数:

  • thread:返回线程ID
  • attr:设置线程的属性,attr为NULL表示使用默认属性
  • start_routine:是个函数地址,线程启动后要执行的函数
  • arg:传给线程启动函数的参数

返回值:成功返回0;失败返回错误码

错误检查:

  • 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
  • pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回。
  • pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小。

下面我们来使用一下这个函数创建线程:

void *threadRoutine(void *args)
{
    while (true)
    {
        cout << "new thread, pid: " << getpid() << endl;
        sleep(1);
    }

    return nullptr //走到这里默认线程退出了!   
}

int main()
{
    // PTHREAD_CANCELED;
    // 是一个很大的数字
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"Thread 1"); // 不是系统调用

    while (true)
    {
        cout << "main thread, pid: " << getpid() << endl;
        sleep(1);   
    }

    return 0;
}

makefile文件:

mythread:mythread.cc
	g++ -o $@ $^ -lpthread
clean:
	rm -f mythread

注意要加上-lpthread选项 

运行结果:

【Linux】多线程_第10张图片

线程id跟进程id一样的线程是主线程。我们看到每个线程的id不一样,说明线程是操作系统调度的基本单位。

我们再来做几个实验验证一下上面讲的理论:

1.线程的健壮性差:一个线程被kill整个进程就被kill

【Linux】多线程_第11张图片

我们看到,不管是kill新线程还是主线程,都会把整个进程给kill掉。验证了线程健壮性差的缺点。

一个线程出现异常,整个进程都退出:

#include 
#include 
#include 

using namespace std;

// new thread
void *threadRoutine(void *args)
{
    while (true)
    {
        cout << "new thread, pid: " << getpid() << endl;
        sleep(1);
        int a = 10;
        a /= 0;
    }
}

int main()
{
    // PTHREAD_CANCELED;
    // 是一个很大的数字
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"Thread 1"); // 不是系统调用

    sleep(1); // 只是为了保证新线程已经启动

    while (true)
    {
        cout << "main thread, pid: " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

【Linux】多线程_第12张图片

可以看到只要其中一个线程出现除零错误,整个进程都退出了,这也说明了线程的健壮性差。

2. 进程的多个线程共享

同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程
中都可以调用,如果定义一个全局变量

#include 
#include 
#include 

using namespace std;

int g_val = 100;

void show(const string &name)
{
    cout << name << "say# "
    << "hello thread" << endl;
}

// new thread
void *threadRoutine(void *args)
{
    const char *name = (const char*)args;
    while (true)
    {
        printf("%s, pid: %d, g_val: %d, &g_val: 0x%p\n", name, getpid(), g_val, &g_val);
        show("[new thread]");
        sleep(1);
    }
}

int main()
{
    // PTHREAD_CANCELED;
    // 是一个很大的数字
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"Thread 1"); // 不是系统调用

    sleep(1); // 只是为了保证新线程已经启动

    while (true)
    {
        printf("main thread pid: %d, g_val: %d, &g_val: 0x%p, create new thread tid: %p\n", getpid(), g_val, &g_val, tid);

        show("[main thread]");
        sleep(1);
        g_val++;    
    }

    return 0;
}

运行结果: 

【Linux】多线程_第13张图片

我们看到两个线程都可以调用show函数和使用g_val全局变量,且主线程对g_val进行修改,新线程也可以看到修改后的值。

3. 线程等待 

为什么需要线程等待?

  • 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
  • 创建新的线程不会复用刚才退出线程的地址空间。 

线程也是需要被等待的,如果不等待,可能会导致类似于“僵尸进程”的问题。

下面来给大家介绍一个线程等待的函数——pthread_join()

功能: 等待线程结束

pthread_join函数的函数原型如下:

【Linux】多线程_第14张图片

参数:

  • thread:线程ID
  • value_ptr:它指向一个指针,后者指向线程的返回值

返回值:成功返回0;失败返回错误码 

下面我们就来使用一下这个函数:

#include 
#include 
#include 

using namespace std;

// new thread
void *threadRoutine(void *args)
{
    // const char *name = (const char*)args;
    int cnt = 5;
    while (true)
    {
        cout << "new thread, pid: " << getpid() << endl;
        sleep(1);

        cnt--;
        if(cnt == 0) break;
    }

    return (void*)100; //走到这里默认线程退出了!
}

int main()
{
    // PTHREAD_CANCELED;
    // 是一个很大的数字
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"Thread 1"); // 不是系统调用

    sleep(7);
    void *retval;
    pthread_join(tid, &retval);// main thread等待的时候,默认是阻塞等待的!为什么我们在这里join的时候不考虑异常呢??做不到!

    cout << "main thread quit ..., ret: " << (long long int)retval << endl;
    return 0;
}

运行结果:

我们通过监控脚本查看线程的运行状态:

while :; do ps -aL | head -1 && ps -aL | grep mythread; sleep 1; done

【Linux】多线程_第15张图片

我们看到主线程成功等待另一个线程退出,通过retval指针收到函数的返回值。通过监控脚本我们看到新线程退出后对主线程没有影响,说明线程等待的时候,默认是阻塞等待的!不关心线程的退出码,将join函数的第二个参数设置为nullptr即可。)

我们之前学习进程等待的时候,我们可以通过wait函数或者是waitpid函数的输出型参数status,获取到进程的退出码、退出信号以及core dump标志。

我们的线程和进程一样,退出有以下三种情况:

  • 代码跑完,结果正确
  • 代码跑完,结果不正确
  • 代码异常了

我们可以通过join的第二个参数拿到线程的返回值,从而知道线程跑完,结果正确还是不正确。那我们的pthread_join能或者需要处理代码异常的情况嘛?根本就不需要,因为线程是进程的一个执行分支,如果进程中的某个线程崩溃了,会导致整个进程都崩溃,因此这根本就不是我们线程该管的事情,要管也是交给进程去管。

4. 线程终止

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

  • 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用线程函数直接exit,整个进程退出。
  • 线程可以调用pthread_ exit函数终止自己。
  • 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

下面我们就来介绍一下上面的三种方法

return退出

在线程函数中使用return表示当前线程退出,如果在main函数中使用return则代表进程退出,也就是说只要主线程退出了就相当于整个进程也就退出了,此时我们进程曾经申请的那些资源都会被释放,然后它缩创建的那些线程也会自动退出。

上面线程等待我们就用了return退出线程,这里就不再进行演示,可以看一下上面线程等待的代码和运行结果。

【Linux】多线程_第16张图片

pthread_exit函数终止线程

除了上面return可以终止线程外,下面我们再来介绍一个函数——pthread_exit()

功能: 终止一个线程

函数原型如下:

void pthread_exit(void *retval);

参数:

  • retval:不要指向一个局部变量

返回值:

  • 无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

注意:

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

using namespace std;


// new thread
void *threadRoutine(void *args)
{
    // const char *name = (const char*)args;
    int cnt = 5;
    while (true)
    {
        cout << "new thread, pid: " << getpid() << endl;
        sleep(1);

        cnt--;
        if(cnt == 0) break;
    }

    pthread_exit((void*)100);
}

int main()
{
    // PTHREAD_CANCELED;
    // 是一个很大的数字
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"Thread 1"); // 不是系统调用

    sleep(7);
    void *retval;
    pthread_join(tid, &retval);// main thread等待的时候,默认是阻塞等待的!为什么我们在这里join的时候不考虑异常呢??做不到!

    cout << "main thread quit ..., ret: " << (long long int)retval << endl;
    return 0;
}

运行结果:

 【Linux】多线程_第17张图片

pthread_cancel函数 

功能: 取消一个正在执行中的线程

函数原型如下:

int pthread_cancel(pthread_t thread);

参数:

  • thread:线程ID

返回值:

  • 成功返回0,失败返回错误码

下面我们来使用一下这个函数:

#include 
#include 
#include 
#include 

using namespace std;

// new thread
void *threadRoutine(void *args)
{
    while (true)
    {
        cout << "new thread, pid: " << getpid() << endl;
        sleep(1);
    }
 
    pthread_exit((void*)100);
}

int main()
{
    // PTHREAD_CANCELED;
    // 是一个很大的数字
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRoutine, (void*)"Thread 1"); // 不是系统调用

    sleep(1); // 只是为了保证新线程已经启动
    pthread_cancel(tid); // 不常见

    void *retval;
    pthread_join(tid, &retval);    // main thread等待的时候,默认是阻塞等待的!为什么我们在这里join的时候不考虑异常呢??做不到!

    cout << "main thread quit ..., ret: " << (long long int)retval << endl;
    return 0;
}

运行结果:

【Linux】多线程_第18张图片

可以看到线程被退出了,且收到的退出码为-1. 

既然主线程可以取消新线程,那我们的新线程能不能取消我们的主线程呢?其实是可以的,但是并不建议这么做。因为主线程会出现类似于“僵尸进程”的问题。

注意:

线程之间地位都是对等的,因此我们既可以使用主线程去取消新线程,也可以使用新线程去取消主线程。我们使用主线程去取消新线程,只会导致新线程终止,其他线程还会正常执行。但是如果我们使用新线程去终止主线程,会导致主线程不再执行后续代码,并且出现类似于僵尸进程的问题。因此即使我们可以使用新线程去终止主线程,但是不建议这么做。

5. 重谈线程的参数和返回值

上面我们创建线程的时候,给线程函数传递的参数只是传递一些一般参数。其实线程的参数和返回值,不仅仅可以用来进行传递一般参数,也可以传递对象!!

我们来看下面这段代码:

#include 
#include 
#include 
#include 

using namespace std;

class Request
{
public:
    Request(int start,int end,string threadname)
    :_start(start),_end(end),_threadname(threadname)
    {}
public:
    int _start;
    int _end;
    string _threadname;
};

class Response
{
public:
    Response(int result,int exitcode)
    :_result(result),_exitcode(exitcode)
    {}
public:
    int _result;
    int _exitcode;
};

void* SumCount(void* args)
{
    Request* rq = static_cast(args);
    Response* rsp = new Response(0,0);

    for(int i = rq->_start; i <= rq->_end; ++i)
    {
        cout << rq->_threadname << "is running calling... " << i <_result+=i;
        usleep(100000);
    }
    delete rq;
    return rsp;
}

int main()
{
    pthread_t tid;
    Request* rq = new Request(1,100,"thread 1");
    pthread_create(&tid,nullptr,SumCount,rq);

    void *ret;
    pthread_join(tid,&ret);
    Response* rsp = static_cast(ret);

    cout << "rsp->result: " << rsp->_result << ", exitcode: " <_exitcode << endl;
    delete(rsp);
    
    return 0;
}

运行结果:
【Linux】多线程_第19张图片

通过上面的例子我们验证了线程的参数和返回值,不仅仅可以用来进行传递一般参数,也可以传递对象!! 我们还看到我们在主线程和新线程都申请了堆空间的变量,而且作为参数传递和使用。说明堆空间也是线程共享的!

6. C++11多线程vs原生线程库

我们上面讲的都是原生线程,pthread库,也叫原生线程库

其实C++11本身也支持多线程了,使用起来也比原生线程库要方便,下面我们来简单使用一下C++11的多线程:

void threadrun()
{
    while(true)
    {
        cout << "I am a new thead for C++" << endl;
        sleep(1);
    }
}

int main()
{
    thread t1(threadrun);
    t1.join();
    return 0;
}

makefile文件:

mythread:mythread.cc
	g++ -o $@ $^ -g -std=c++11 -lpthread
clean:
	rm -f mythread

注意要加上c++11和lpthread选项。

运行结果:

【Linux】多线程_第20张图片

可以看到我们成功创建出新线程。我们还发现主线程的id和进程的pid是一致的,和我们原生线程库是一样的。这是因为其实C++11多线程库就是用原生线程库进行封装的。 

C++11多线程vs原生线程库:

C++11多线程:

  • 简洁的语法:C++11多线程提供了简洁的语法,使得线程的创建、同步和通信变得更加简单。
  • 跨平台:C++11多线程是标准库的一部分,可以在不同的操作系统上使用。原生线程库则依赖于特定的操作系统
  • 异常安全:C++11多线程库提供了异常安全机制,可以避免在多线程环境中出现异常时的数据竞争和其他问题。

原生线程库:

  • 直接与操作系统线程交互,具有更高的性能和更低的延迟。
  • 需要处理许多底层细节,如线程创建、同步、通信等,编程难度较高。
  • 不同的操作系统提供了不同的原生线程库,跨平台兼容性较差。

总结:原生线程库具有较高的性能和灵活性,但需要处理底层细节,且跨平台兼容性较差。 而C++11线程库的跨平台性较好,如果我们需要跨平台编程,建议使用C++11线程库。

7. 创建多个线程 

(1)前面如何创建一个线程我已经学会了,那我们应该一次如何创建多个线程呢?

下面我们来尝试创建多个线程:

#include 
#include 
#include 
#include 
using namespace std;

#define NUM 10

struct threadData
{
    string threadname;
};

string toHex(pthread_t tid)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%x", tid);
    return buffer;
}

void InitThreadData(threadData* td,int number)
{
    td->threadname = "thread-" + to_string(number);
}

void* threadRountine(void* args)
{
    int test_i = 0;
    threadData* td = static_cast(args);
    string tid = toHex(pthread_self());
    int pid = getpid();

    int i = 0;
    while (i < 10)
    {
        // cout << "tid:" << tid << ",pid:" << pid << endl;
        cout << "pid: " << getpid() << ", tid : " << tid 
        << ", threadname: " << td->threadname < tids;
    for(int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        threadData* td = new threadData;//这里要用new在堆上创建。如果直接定义,这里是在主线程的栈上创建,而且是在for循环,循环结束其它线程就没办法访问了
        InitThreadData(td,i);

        pthread_create(&tid,nullptr,threadRountine,td);
        tids.push_back(tid);
        // sleep(1);
    }
    sleep(1); // 确保复制成功

    for (int i = 0; i < tids.size(); i++)
    {
        int n = pthread_join(tids[i],nullptr);
    }
    
    return 0;
}

运行结果: 

【Linux】多线程_第21张图片

可以看到我们成功创建出来十个线程。

(2)前面我们说过每一个线程都有自己的栈结构,保护各个线程运行时所形成的临时数据,独立的栈结构保证线程调度的过程中不会出现线程错乱的问题

下面我们利用多线程来进行验证:

#include 
#include 
#include 
#include 
using namespace std;

#define NUM 3

struct threadData
{
    string threadname;
};

string toHex(pthread_t tid)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%x", tid);
    return buffer;
}

void InitThreadData(threadData* td,int number)
{
    td->threadname = "thread-" + to_string(number);
}

void* threadRountine(void* args)
{
    int test_i = 0;
    threadData* td = static_cast(args);
    string tid = toHex(pthread_self());
    int pid = getpid();

    int i = 0;
    while (i < 10)
    {
        cout << "pid: " << getpid() << ", tid : " << tid 
        << ", threadname: " << td->threadname 
        << " test_i: " << test_i << " &test_i: " << &test_i << endl;
        sleep(1);
        i++; test_i++;
    }

    delete td;
    return nullptr;
}

int main()
{
    //创建多线程
    vector tids;
    for(int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        threadData* td = new threadData;//这里要用new在堆上创建。如果直接定义,这里是在主线程的栈上创建,而且是在for循环,循环结束其它线程就没办法访问了
        InitThreadData(td,i);

        pthread_create(&tid,nullptr,threadRountine,td);
        tids.push_back(tid);
        // sleep(1);
    }
    sleep(1); // 确保复制成功

    for (int i = 0; i < tids.size(); i++)
    {
        int n = pthread_join(tids[i],nullptr);
    }
    
    return 0;
}

运行结果:

【Linux】多线程_第22张图片

我们看到虽然每个线程调用的是同一个函数,且test_i的值都是依次从0开始增长。但是我们看到每个线程的test_i的地址却是不一样的。

这是因为每个线程都有自己独立的栈结构,各个线程调用这个函数时都要在自己独立的栈结构开辟栈帧,各自在自己的栈上创建了一个test_i。

其实线程和线程之间,几乎没有秘密,线程的栈上的数据,也是可以被其他线程看到并访问的。

下面我们来进行验证:

#include 
#include 
#include 
#include 
using namespace std;

#define NUM 3

int *p = NULL;
int g_val = 100;

struct threadData
{
    string threadname;
};

string toHex(pthread_t tid)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%x", tid);
    return buffer;
}

void InitThreadData(threadData* td,int number)
{
    td->threadname = "thread-" + to_string(number);
}

void* threadRountine(void* args)
{
    int test_i = 0;
    threadData* td = static_cast(args);
    if(td->threadname == "thread-2") p = &test_i;
    string tid = toHex(pthread_self());
    int pid = getpid();

    int i = 0;
    while (i < 10)
    {
        cout << "pid: " << getpid() << ", tid : " << tid 
        << ", threadname: " << td->threadname 
        << " test_i: " << test_i << " &test_i: " << &test_i << endl;
        sleep(1);
        i++; test_i++;
    }

    delete td;
    return nullptr;
}

int main()
{
    //创建多线程
    vector tids;
    for(int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        threadData* td = new threadData;//这里要用new在堆上创建。如果直接定义,这里是在主线程的栈上创建,而且是在for循环,循环结束其它线程就没办法访问了
        InitThreadData(td,i);

        pthread_create(&tid,nullptr,threadRountine,td);
        tids.push_back(tid);
    }
    sleep(1); // 确保复制成功

    cout << "main thread get a thread local value, val: " << *p << ", &val: " << p << endl;

    for (int i = 0; i < tids.size(); i++)
    {
        int n = pthread_join(tids[i],nullptr);
    }
    
    return 0;
}

 运行结果:

【Linux】多线程_第23张图片

(3)我们前面验证过全局变量是可以被所有线程同时看到并访问的,那如果我们想要私有一个全局变量呢?

我们只需要在全局变量的前面加上__thread就可以完成对全局变量进行线程的局部存储了:

#include 
#include 
#include 
#include 
using namespace std;

#define NUM 3

int *p = NULL;
__thread int g_val = 100;

struct threadData
{
    string threadname;
};

string toHex(pthread_t tid)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%x", tid);
    return buffer;
}

void InitThreadData(threadData* td,int number)
{
    td->threadname = "thread-" + to_string(number);
}

void* threadRountine(void* args)
{
    int test_i = 0;
    threadData* td = static_cast(args);
    string tid = toHex(pthread_self());
    int pid = getpid();

    int i = 0;
    while (i < 10)
    {
        cout << "pid: " << getpid() << ", tid : " << tid 
        << ", threadname: " << td->threadname 
        << ", g_val: " << g_val << " ,&g_val: " << &g_val < tids;
    for(int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        threadData* td = new threadData;//这里要用new在堆上创建。如果直接定义,这里是在主线程的栈上创建,而且是在for循环,循环结束其它线程就没办法访问了
        InitThreadData(td,i);

        pthread_create(&tid,nullptr,threadRountine,td);
        tids.push_back(tid);
    }
    sleep(1); // 确保复制成功

    for (int i = 0; i < tids.size(); i++)
    {
        int n = pthread_join(tids[i],nullptr);
    }
    
    return 0;
}

运行结果:

【Linux】多线程_第24张图片

可以看到在全局变量加上__thread之后每个线程的g_val地址都不一样了。

这样定义有什么作用呢?

减少系统调用和实现线程的局部存储。如果我们要用一个变量保存进程的pid或者线程的id,只需要用__thread在全局定义一个变量,然后再调用一次系统调用进行保存。后面我们就只需要调用这个变量就可以了,不需要再调用系统调用了

有人会问线程不是有独立的栈结构吗?那我们在线程函数直接定义局部变量不是一样的吗?

其实这种方法也可以,但是如果我们还要在线程函数里面再调用其他函数,还需要使用到这些变量,就需要将这些变量传进去,就会非常麻烦。

注意:__thread​​​​​​​这个方法只能定义内置类型,不用用来修饰类等自定义类型。

8. 线程分离

  • 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成内存泄漏。
  • 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

所以我们如果不想等待该线程并且也不想造成内存泄漏,我们可以采用线程分离,分离之后的线程不需要被join,运行完毕之后,会自动释放该线程的资源。

下面来为大家介绍一个函数——pthread_detach()

int pthread_detach(pthread_t thread);

参数:

  • thread:被分离线程的ID

返回值:

  • 线程分离成功返回0,失败返回错误码

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:

pthread_detach(pthread_self());

我们下面首先来使用线程组内其他线程对目标线程进行分离

#include 
#include 
#include 
#include 
#include 
using namespace std;

#define NUM 3

int *p = NULL;
__thread int g_val = 100;

struct threadData
{
    string threadname;
};

string toHex(pthread_t tid)
{
    char buffer[128];
    snprintf(buffer, sizeof(buffer), "0x%x", tid);
    return buffer;
}

void InitThreadData(threadData* td,int number)
{
    td->threadname = "thread-" + to_string(number);
}

void* threadRountine(void* args)
{
    int test_i = 0;
    threadData* td = static_cast(args);
    string tid = toHex(pthread_self());
    int pid = getpid();

    int i = 0;
    while (i < 10)
    {
        cout << "pid: " << getpid() << ", tid : " << tid 
        << ", threadname: " << td->threadname 
        << ", g_val: " << g_val << " ,&g_val: " << &g_val < tids;
    for(int i = 0; i < NUM; i++)
    {
        pthread_t tid;
        threadData* td = new threadData;//这里要用new在堆上创建。如果直接定义,这里是在主线程的栈上创建,而且是在for循环,循环结束其它线程就没办法访问了
        InitThreadData(td,i);

        pthread_create(&tid,nullptr,threadRountine,td);
        tids.push_back(tid);
    }
    sleep(1); // 确保复制成功

    for(auto i : tids)
    {
        pthread_detach(i);
    }

    for (int i = 0; i < tids.size(); i++)
    {
        int n = pthread_join(tids[i],nullptr);
        printf("n = %d, who = 0x%x, why: %s\n", n, tids[i], strerror(n));
    }
    
    return 0;
}

运行结果:

【Linux】多线程_第25张图片

我们可以看到这次三个线程只执行了一次。我们还可以看到join的返回值是22,不是0,此时证明我们的join是失败的。这是为什么?

因为joinable和分离是冲突的,一个线程不能既是joinable又是分离的。所以我们三个线程就只执行了一次。

我们也可以使用线程自己分离:

【Linux】多线程_第26张图片

运行结果:

【Linux】多线程_第27张图片

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

  • pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
  • 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
  • pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库(原生线程库)的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
  • 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:

【Linux】多线程_第28张图片

下面我们来使用一下pthread_ self函数:

#include 
#include 
#include 
#include 
#include 

using namespace std;

string toHex(pthread_t tid)
{
    char Hex[64];
    snprintf(Hex,sizeof(Hex),"%p",tid);
    return Hex;
}

void* threadRountine(void* args)
{
    while (true)
    {
        cout << "thread id:" << toHex(pthread_self()) << endl;
        sleep(1);
    }
}

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

    cout << "main thread id : " << toHex(pthread_self()) << endl;
    cout << "main thread create thead done, new thread id : " << toHex(tid) << endl;
    pthread_join(tid,nullptr);
    return 0;
}

运行结果:
【Linux】多线程_第29张图片

我们查看到的线程id是pthread库的线程id,不是Linux内核中的LWP,pthread库的线程id是一个内存地址。

我们通过ldd命令可以看到,我们采用的线程库实际上是一个动态库:

【Linux】多线程_第30张图片

我们知道要想创建线程,首先你得要有一个进程,创建进程就需要创建一堆数据结构,进程创建好了之后我们还需要使用pthread动态库。而pthread动态库在磁盘上面是一个文件,那既然是文件如果我们想使用它,我们就需要把它加载到内存中才行。

进程运行时动态库被加载到内存,然后通过页表映射到进程地址空间中的共享区,此时我们进程内部的所有线程就都可以看到这个动态库。

【Linux】多线程_第31张图片

线程库注定了要维护多个线程属性集合。每个线程都要有运行时的临时数据,这也就意味着每个线程都要有自己的私有栈结构。那我们创建了这么多的用户级线程,线程库如何管理这些线程呢?先描述再组织。

因此我们还需要有描述线程的用户级控制块,这个控制块叫做struct pthread,其中包含了对应线程的各种属性,每个线程还有自己的线程局部存储,当中包含了线程被切换时的上下文数据。

那这个用户级控制块具体是怎么样的呢,我们如何快速找到一个用户级线程呢?

每个线程在共享区都有这样的一块区域对其进行描述,因此我们要找到一个用户级线程我们只需要找到该线程的用户级控制块的起始地址,就可以获取到该线程的各种信息了。

pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。 

【Linux】多线程_第32张图片

主线程使用的栈是进程地址空间中原生的栈。新线程采用的栈是在共享区中开辟的,具体来说是在pthread库中的,tid指向的用户tcb中!

【Linux】多线程_第33张图片

我们上面使用的各种线程函数,本质都是在线程内部对线程属性进行的各种操作,最后将要执行的代码交给对应的内核级LWP去执行就行了,也就是说线程数据的管理是在共享区的。

你可能感兴趣的:(Linux,linux,服务器,开发语言,c++)