【Linux系统】Linux多线程详解

1 前置知识

1.1 进程的概念

进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

1.2 线程的概念

线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

1.3 进程地址空间

每个进程也都有自己的地址空间,这些地址都是虚拟的地址,而不是真实的物理地址。操作系统利用页表方式将进程地址空间和真实的物理地址映射起来。

栈区和堆区之间的箭头表示,随着数据的加入,栈区的地址由高地址向低地址增长
堆区的地址由低地址向高地址增长。
在这里插入图片描述

1.4 由虚拟地址到物理地址的页表映射(二级页表)

1.4.1 一级页表的缺点

在这里插入图片描述

在32位平台下有232个地址,也就是一张页表就要有232个映射关系。
每张表的内容出了映射关系外,还包含了一些权限相关信息。比如页表分为内核级页表和用户级页表,通过权限信息来区分。
在这里插入图片描述
每个表项中存储了一个物理地址和虚拟地址,这里一共要占用8个字节,再考虑权限相关的信息,这里粗略地认为每个表项总共占用了10个字节。

那么总共就需要232 * 10 字节,也就是40GB的大小。
在32位平台下最大的内存也就仅有4GB,说明这种页表映射的方式是不合理的。

1.4.2 二级页表

在Linux中的处理方式是建立一个二级页表。

1.虚拟地址前10个比特位在页目录中进行查找,找到相应的页表。
2.再拿10个比特位在对应页表中进行查询,找到物理内存中对应页框的起始地址。
3.将最后12个比特位作为偏移量从页框对应地址处向后偏移,找到物理内存中的某一个对应字节数据。对应页框的起始地址(20个比特位)+虚拟地址的最后12个比特位就能够定位任意一个内存字节地址。

在这里插入图片描述
物理地址是以“块”为单位的,这个块的大小就是4KB也就是212,对应了偏移量最大值。

这就是二级页表的结构,页目录就是一个一级页表,而表项就是一个二级页表。

接下来计算总大小。
首先只用了20个比特位来建立映射关系,那么最大也就是220个字节,也就是1MB。在页表中,左边占了10个比特位,而右边占了20个比特位,共30个比特位,这里假设加起来一共占了32个比特位(方便计算),也就是4个字节。
那么总大小就是220 * 4 byte = 4MB

映射过程是由MMU这个硬件完成的,页表是一种软件映射,MMU是一种硬件映射。

MMU是Memory Management Unit的缩写,中文名是内存管理单元,有时称作分页内存管理单元(英语:paged memory management unit,缩写为PMMU)。它是一种负责处理中央处理器(CPU)的内存访问请求的计算机硬件。它的功能包括虚拟地址到物理地址的转换(即虚拟内存管理)、内存保护、中央处理器高速缓存的控制,在较为简单的计算机体系结构中,负责总线的仲裁以及存储体切换

1.5 Linux中的进程

在Linux中一个进程的创建实际上伴随着进程控制块(PCB或task_struct),进程地址空间以及页表的创建。

进程控制块(Processing Control Block),是操作系统核心中一种数据结构,主要表示进程状态。其作用是使一个在多道程序环境下不能独立运行的程序(含数据),成为一个能独立运行的基本单位或与其它进程并发执行的进程。或者说,OS是根据PCB来对并发执行的进程进行控制和管理的。 PCB通常是系统内存占用区中的一个连续存区,它存放着操作系统用于描述进程情况及控制进程运行所需的全部信息,它使一个在多道程序环境下不能独立运行的程序成为一个能独立运行的基本单位或一个能与其他进程并发执行的进程。

在这里插入图片描述

2 Linux中的多线程(Linux并不存在真正意义上的线程)

Linux中每创建一个进程,都要伴随着产生进程控制块,进程地址空间,页表。
而操作系统中有大量的进程,一个进程内又可以包含多个线程,因此线程的数量一定会远远多于进程的。如果一个操作系统要真正地去支持使用线程,那么就必须有某种结构对线程进行相应的管理。比如:线程的创建,终止,转换,调度和释放回收等。
但是Linux并没有单独地为线程创建相应的结构去管理,而是复用了进程的结构。而Windows系统单独为线程创建了相应的结构去管理,会比Linux复杂很多。

在这里插入图片描述

由上图可以很好地理解线程为什么是CPU调度的基本单位,在CPU看来,它只关心一个独立的执行流,无论进程内部是一个还是多个执行流,CPU都是以task_struct为单位来调度的。在CPU看来,Linux中的进程比传统中的进程更加轻量化。进程的执行流我们叫轻量化进程。
也能很好地理解了为什么进程是分配资源的基本单位,因为进程之间是相互独立的,每个进程都有相应的进程地址空间。

2.1 线程的优点

创建一个新线程的代价要比创建一个新进程小得多

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

线程占用的资源要比进程少很多

能充分利用多处理器的可并行数量 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务

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

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

2.2 线程的缺点

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

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

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

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

2.3 线程异常

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

2.4 线程用途

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

2.5 线程 VS 进程

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

线程ID; 一组寄存器; 栈; errno; 信号屏蔽字; 调度优先级

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

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

进程和线程的关系如下图:
在这里插入图片描述

3 Linux 线程控制

在操作系统的的视角下,Linux下没有真正意义的线程,而是用进程模拟的线程(LWP,轻量级进程),所以Linux不会提供直接创建线程的系统调用,最多提供创建轻量级进程的接口。
但是对于用户来说,用户需要的是线程接口。
所以Linux提供了用户线程库,对下将Linux接口封装,对上给用户提供进行线程控制的接口,也就是pthread库(原生线程库)

PROSIX线程库

与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
要使用这些函数库,要通过引入头文<pthread.h>

链接这些线程函数库时要使用编译器命令的“-lpthread”选项

3.1 创建线程

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变量的开销更小

代码示例:创建一个新线程

#include <iostream>
#include <unistd.h>
using namespace std;

void* thread_run(void* args)
{
    while(true)
    {
        cout << "new thread running : " << endl;
        sleep(1);
    }
}
int main()
{
    pthread_t t;
    //int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
    pthread_create(&t,nullptr,thread_run,nullptr);
      
    while(true)
    {
        cout << "main thread running"<< endl;
        sleep(1);
    }
    return 0;
}

运行结果:
在这里插入图片描述
由打印结果可以看到,主线程和新线程都打印了相应的字符串。
使用

ps -aL | head -1 && ps -aL | grep test1

命令查看执行的线程,test1是C++可执行文件
在这里插入图片描述
这里LWP是轻量级进程ID。因为它们属于同一个进程,所以进程PID相同。

3.2 线程ID及地址空间布局

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

 pthread_t pthread_self(void);

使用示例:

void* thread_run(void* args)
{
    while(true)
    {
        cout << "new thread running,thread id: " <<  pthread_self() << endl;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    pthread_t t;
    pthread_create(&t,nullptr,thread_run,nullptr);
    while(true)
    {
        cout << "main thread running,thread id: " <<  pthread_self() << endl;
        sleep(2);
    }
    return 0;
}

在这里插入图片描述

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

下图中的mmap区域是共享区
在这里插入图片描述

3.3 线程终止

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

1.线程函数处进行return。
2.线程可以自己调用pthread_exit函数终止自己。
3.一个线程可以调用pthread_cancel函数终止同一进程中的另一个线程。

线程函数处进行return
注意:在线程中使用return代表该线程退出,而在main函数(主线程)中使用return代表整个进程退出。

void* thread_run(void* args)
{
    while(true)
    {
        cout << "new thread running,thread id: " <<  pthread_self() << endl;
        sleep(1);
    }
    return nullptr;
}
int main()
{
    pthread_t t;
    pthread_create(&t,nullptr,thread_run,nullptr);
    return 0;
}

该代码并不会打印新线程中的字符串,因为主线程退出整个进程都终止了。

使用pthread_exit函数

void pthread_exit(void *retval);

retval:线程退出时的退出码信息,该函数无返回值,跟进程一样结束时无法返回自身。

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

using namespace std;

void* thread_run(void* args)
{
    int cnt = 5;
    while(cnt--)
    {
        cout << "new thread running,thread id: " <<  pthread_self() << endl;
        sleep(1);
    }
    pthread_exit((void*)2023);//将线程退出码设为2023
}
int main()
{
    pthread_t t;
    pthread_create(&t,nullptr,thread_run,nullptr);
    void* ret = nullptr;
    pthread_join(t,&ret);//pthread_join表示线程等待,主线程执行完后还需等待其他线程
    //这里会把线程退出码信息通过该函数给ret
    cout << "new thread exit code is : " << (int64_t)ret << endl;
    //这里使用int64_t强制转换是因为平台下Linux的指针是8字节的。
    return 0;
}

在这里插入图片描述
在线程等待的情况下,新线程在5秒后结束了并返回了线程退出码。

pthread_cancel函数

int pthread_cancel(pthread_t thread);

功能:取消一个执行中的线程
参数

thread:线程ID

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

线程是可以取消自己的,甚至新线程也可以取消主线程,取消成功的线程的退出码一般是 -1

void* thread_run(void* args)
{
   int cnt = 5;
    while(cnt--)
    {
        cout << "new thread running,thread id: " <<  pthread_self() << endl;
        sleep(1);
    }
    pthread_exit((void*)2023);
}
int main()
{
    pthread_t t;
    pthread_create(&t,nullptr,thread_run,nullptr);
    sleep(3);
    pthread_cancel(t);//取消新线程
    void* ret = nullptr;
    pthread_join(t,&ret);//pthread_join表示线程等待,主线程执行完后还需等待其他线程
    cout << "new thread exit code is : " << (int64_t)ret << endl;
    return 0;
}

在这里插入图片描述
进程退出码变成-1

3.4 线程等待

为什么需要线程等待?

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

线程等待函数

int pthread_join(pthread_t thread, void **retval);

功能:等待线程结束;

thread:被等待线程的ID,retval:线程退出时的退出码信息,线程等待成功返回0,失败返回错误码

retval:
如果thread线程通过return返回,retval 所指向的单元里存放的是thread线程函数的返回值。
如果thread线程被别的线程调用 pthread_cancel 异常终止掉,retval 所指向的单元里存放的是常数PTHREAD_CANCELED,就是-1
如果thread线程是自己调用 pthread_exit 终止的,retval 所指向的单元存放的是传给 pthread_exit 的参数。
如果对thread线程的终止状态不感兴趣,可以传 NULL 给 retval 参数

3.5 线程分离

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

int pthread_detach(pthread_t thread);

可以线程组内其他线程对目标线程进行分离,也可以是线程自己分离。
当一个线程分离后,是不能够被join的。

错误使用示例:

void* thread_run(void* args)
{
    char* name = static_cast<char*>(args);
    int cnt = 5;
    while(cnt)
    {
        cout << name << " : " << cnt -- << endl;
    }
    return nullptr;
}
int main()
{
    pthread_t t;
    pthread_create(&t,nullptr,thread_run,(void*)"thread_1");
    pthread_detach(t);
    int n = pthread_join(t,nullptr);//error,线程分离后不能join
    if(n != 0)
    {
        cerr << "error : " << n << " : " << strerror(n) << endl; 
    }
    return 0;
}

这里可能会出现两种情况,一种是线程1先执行完,再提示出main函数里的错误打印。
一种是直接错误打印。
在这里插入图片描述
原因是,线程之间谁先执行是不确定的。假设线程1先执行,因为代码没有sleep函数,执行完也就一瞬间的事情。线程1执行完后,接下来是主线程执行,主线程这时候才去判断有没有join,所以会导致最后才打印错误信息。

假设主线程先执行,发现线程分离了以后还join了,所以程序之间报错,结束程序。

正确使用示例:

void* thread_run(void* args)
{
    char* name = static_cast<char*>(args);
    int cnt = 5;
    while(cnt)
    {
        cout << name << " : " << cnt -- << endl;
    }
    return nullptr;
}
int main()
{
    pthread_t t;
    pthread_create(&t,nullptr,thread_run,(void*)"thread_1");
    pthread_detach(t);
    return 0;
}

在这里插入图片描述
这里反复运行,发现会有两种情况,一种是主线程先执行,主线程执行结束直接return,进程之间结束。
另外一种是线程1先执行,再到主线程。
这里主要是想说明,线程分离并不影响“主线程结束,导致其他线程被迫退出”的情况。线程分离后,主线程执行结束,各线程会主动释放空间,避免僵尸进程的情况。

让主线程的休眠时间大于新线程即可让新线程先打印

void* thread_run(void* args)
{
    char* name = static_cast<char*>(args);
    int cnt = 5;
    while(cnt)
    {
        sleep(1);
        cout << name << " : " << cnt -- << endl;
    }
    return nullptr;
}
int main()
{
    pthread_t t;
    pthread_create(&t,nullptr,thread_run,(void*)"thread_1");
    pthread_detach(t);
    sleep(10);
    return 0;
}

在这里插入图片描述

  • 8
    点赞
  • 51
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

有心栽花无心插柳

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值