【Linux】Linux多线程

怎么理解线程

通俗易懂的说,线程的运行是必须有进程给它打个基础。线程的运行是服用进程的代码及空间来进行运作的。

那么什么是线程?

线程是进程内部的一个分支,执行的粒度比进程更细,调用的成本更低。
此外,线程是cpu调度的基本单位,而进程是系统分配资源的基本实体。

  • 线程是进行内部的一个执行分支: 进程的代码被分为一个个部分有不同的线程来执行,线程提供了该部分的执行入口,操作系统会并发的调度各个进程
  • 线程执行粒度比进程更细: 每个线程一般只是执行进程代码中的一部分,且线程的创建一般是多个的,将进程瓜分的更细。
  • 线程的调度成本比进程更低:线程切换时,不需要修改地址空间和页表。

那么怎么理解进程和线程的区别

我先简单用文字描述以下,然后再看一下逻辑图

进程:实际上,进程包括进程控制块PCB(task_struct)、进程地址空间(虚拟内存)、以及页表和需要映射的物理内存。
当我们创建多个进程的时候, 这时它们都是独立的,它们都有属于自己的一套结构包括PCB。
在这里插入图片描述

线程:在上面的介绍中我们说到,线程是服用进程的那一套结构来完成的,那么线程自己是怎么实现服用和管理的呢?
因为线程可以有多个,多个线程的创建其实都是通过PCB(task_struct)的分支来完成的,分成多份来供线程使用。它们总体都是通过pthread_t来进程管理的

在这里插入图片描述

线程时CPU调度的基本单位
CPU只是机械的执行操作系统传入的命令,它区分不出进程和多线成,只是操作系统将task_struct以及相关的数据传入CPU,CPU就会根据task_struct执行相应的代码,无论进程内部只有一个执行流还是多个执行流,CPU都是以task_struct为单位进程调度的,因此称线程是CPU调度的基本单本
在这里插入图片描述

多线程下进程是系统分配资源的基本实体
进程是由一个或多个task_struct构成的执行流、进程地址空间、页表、代码和数据组成
在这里插入图片描述
只有在创建进程时,操作系统才会为其申请内存资源创建地址空间等结构和加载代码和数据,因此进程是系统分配资源的基本实体,因为操作系统会给进程分配系统资源,才会有内存空间用于task_struct的创建来实现多线程。

线程的优缺点

线程的优点

  • 多线程之间可以共享数据资源和数据,避免了进程间通信的开销和复杂性
  • 线程的创建和开销相对较小,相比进程来说更轻量级,因此被称为轻量级进程(LWP)。
  • 多线程可以实现任务的并行处理,提高系统的响应速度和吞吐量。

线程的缺点

线程的二级页表

注:二级页表用于32位计算机,64位计算机需采用三级页表,二级页表与三级页表的使用原理相同。

我们知道一个文件进行IO每次是以4KB来进行的,也就是八个扇区,然后对数据块进行整体的管理。
在我们的线程内部,我们每次存储也是按照4KB进行存储的。不管是物理内存还是磁盘,都是os统一的。

在这里插入图片描述
内存管理本质:将磁盘中的特定数据块(数据内容)存放到物理内存中的哪个页框(数据加载的空间)
将内存划分成一个个页框后,操作系统就可以
使用数组结构描述物理内存
然后使用该结构对物理内存继续管理。值得注意的是,根据局部性原理,从磁盘中加载一个数据块到内存中,实际上就是一个预加载操作,能够减少IO的次数,提高整机效率。

32位计算机物理内存大小为4GB,物理内存的基本单位是字节,因此表示物理内存的地址需要32位比特位,在使用二级页表时将这32位的地址划分为10+10+12三个部分:

在这里插入图片描述
前10位地址作用于第一级页表(页目录),页目录中会存储前10位地址所组成的所有二进制地址的映射关系,映射到对应的第二级页表(页表项)。中间的10位地址作用于第二级页表(页表项),由于每个页表项是根据对应的页目录映射找到的,因此页表项中的10位地址实际建立的是前20位地址所组成的所有二进制地址的映射关系,映射到对应的页框。最后12位地址作用于页框中,当通过二级页表找到对应页框后,根据页框首地址偏移后12位地址所表示的大小找到对应数据的首地址。使用二级页表寻找对应数据的示意图如下:
在这里插入图片描述
实际上页表还有其他保存的选项,例如
在这里插入图片描述

线程用途

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

怎么管理线程

线程头文件
#include <pthread,h>
在编译的时候,我门需要在末尾加上-lpthread

线程创建

创建线程的函数叫做pthread_create

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

参数说明:

  • thread:获取创建成功线程的ID,该参数是一个输出型参数。
  • attr:用于设置创建线程的属性,传入NULL表示默认属性。
  • start_routinue:该参数是一个函数地址,表示线程例程,即线程启动后要执行的函数。
  • arg:传入线程例程的参数,也就是要执行函数的参数。

返回值:线程创建成功返回0,失败返回错误码

主线程是什么
当一个程序启动后,就有一个进程被操作系统创建,与此同时一个线程也立刻被运行,这个线程就叫主线程。

  • 主线程是产生其他子线程的线程。
  • 通常主线程必须最后完成某些操作,比如各种关闭操作。

下面我们让主线程调用pthread_create函数创建一个新线程,此后新线程就会跑去执行自己的新例程,而主线程则继续执行后续代码。

在这里新线程和主线程的运行是没有规律的,没有谁前谁后。

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

using namespace std;

void* func(void* arg)
{
    char* msg = (char*)arg;
	while (1){
		printf("I am %s\n", msg);
		sleep(1);
	}
}

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

    cout << tid << endl;
    sleep(10);//这里睡眠十秒钟的作用是不让主线程过早的退出
    //给分支线程足够的运行时间
    return 0;
}

在这里插入图片描述

每一个线程都是通过主线程分支得到的,当主线程异常退出的时候,其他的分支进程也会随之退出。
我们可以通过 ps -aL命令来查看进行的多线程和主线程

  • 带-L可以查看到每个进程内的多个轻量级进程。

下面我们可以多创建几个线程,来查看一下它们之间的关系

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

using namespace std;

void* func(void* arg)
{
    char* msg = (char*)arg;
	while (1){
		printf("I am %s,pid:%d,ppid:%d\n", msg,getpid(),getppid());
		sleep(1);
	}
}

int main()
{
    pthread_t tid[4];
    for(int i = 0;i < 4;i++)
    {
        pthread_create(tid + i, NULL, func, (void*)"thread 1");
        cout << tid[i] << endl;
        sleep(1);
    }
    sleep(100);
    return 0;
}

在这里插入图片描述
我们从图中可以看出,他们的 PID 和 PPID都是一样的,这说明它们属于同一个线程,但是他们的LWP是不相等的,从图中我们可以看到有五个线程,其中,第一个线程为主线程,当主线程异常退出时,剩余的进程也全都会随之退出。

LWP表示轻量级进程的ID,代表对线程ID,我们可以看出,每个线程的ID都是不一样的,实际操作系统调用的时候都是调用的LWP,而并非PID,只不过我们之前接触到的都是单线程进程,其PID和LWP是相等的,所以对于单线程进程来说,调度时采用PID和LWP是一样的。

获取创建后的ID方法
  • 获取创建成功的ID方法有两种
    • 一种为我们调用创建函数时,有一个输出型参数,将我们创建好的线程ID给返回了。
    • 另一种方法为调用线程这块的函数来进行返回pthread_self

pthread_self函数的函数原型如下:

pthread_t pthread_self(void);

我们用以下代码来尝试一下以下案例:

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

using namespace std;

void* func(void* arg)
{
    char* msg = (char*)arg;
	while (1){
		printf("I am %s,ID:%d,pid:%d,ppid:%d\n", msg,pthread_self(),getpid(),getppid());
		sleep(1);
	}
}

int main()
{
    pthread_t tid[4];
    char str[125];
    for(int i = 0;i < 4;i++)
    {
        sprintf(str,"thread: %d",i);
        pthread_create(tid + i, NULL, func, str);
        printf("我是主线程我的线程ID为:%d\n",tid[i]);
        sleep(1);
    }
    sleep(100);
    return 0;
}

在这里插入图片描述
注意: 用pthread_self函数获得的线程ID与内核的LWP的值是不相等的,pthread_self函数获得的是用户级原生线程库的线程ID,而LWP是内核的轻量级进程ID,它们之间是一对一的关系。

线程等待

当我们进行运行时,由于主线程和新建的线程的运行轨迹不一样,他们的生命周期也是不统一的,就像进程的父进程和子进程一样,如果新建的线程没有主线程的阻塞等待,那么他就会产生内存泄漏

等待线程的函叫做 pthread_join

pthread_join函数的函数原型如下:

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

参数说明:

  • thread:被等待线程的ID
  • retval:线程退出时的退出码信息

返回值说明:

  • 线程等待成功返回0,失败返回错误码。

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

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

不获取返回值阻塞式等待运行

void* func(void* arg)
{
    char* msg = (char*)arg;
    int cnt = 6;
	while (cnt <= 6){
		printf("I am %s,ID:%d,pid:%d,ppid:%d\n", msg,pthread_self(),getpid(),getppid());
		sleep(1);
        cnt++;
	}
}

int main()
{
    pthread_t tid[4];
    char str[125];
    for(int i = 0;i < 4;i++)
    {
        sprintf(str,"thread: %d",i);
        pthread_create(tid + i, NULL, func, str);
        printf("我是主线程我的线程ID为:%d\n",tid[i]);
        sleep(1);
    }
    for(int i = 0;i < 4;i++)
    {
        pthread_join(tid[i],NULL);
        printf("tid[%d]:%d\n",i,tid[i]);
    }
    return 0;
}

在这里插入图片描述
如果要进行接受函数退出的返回值,这里,我们要注意它的接受类型

int pthread_join(pthread_t thread, void **retval);
#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

void* func(void* arg)
{
    char* msg = (char*)arg;
    int cnt = 6;
	while (cnt <= 6){
		printf("I am %s,ID:%d,pid:%d,ppid:%d\n", msg,pthread_self(),getpid(),getppid());
		sleep(1);
        cnt++;
	}

    return (void*)6666;
}

int main()
{
    pthread_t tid[4];
    char str[125];
    for(int i = 0;i < 4;i++)
    {
        sprintf(str,"thread: %d",i);
        pthread_create(tid + i, NULL, func, str);
        printf("我是主线程我的线程ID为:%d\n",tid[i]);
        sleep(1);
    }
    for(int i = 0;i < 4;i++)
    {
        void* ret = NULL;
        pthread_join(tid[i],&ret);
        printf("tid[%d]:%d,返回值为:%d\n",i,tid[i],(int*)ret);
    }
    return 0;
}

在这里插入图片描述
当我们进行阻塞式线程等待时,当我们其中的一个线程出现异常错误时,整个进程都会异常退出,这时我们的阻塞等待函数是起不到阻塞作用的。

我们通过下边的代码来演示看一下

void* func(void* arg)
{
    char* msg = (char*)arg;
    int cnt = 6;
	while (cnt <= 6){
		printf("I am %s,ID:%d,pid:%d,ppid:%d,计算%d\n", msg,pthread_self(),getpid(),getppid(),10/0);
		sleep(1);
        cnt++;
	}

    return (void*)6666;
}

int main()
{
    pthread_t tid[4];
    char str[125];
    for(int i = 0;i < 4;i++)
    {
        sprintf(str,"thread: %d",i);
        pthread_create(tid + i, NULL, func, str);
        printf("我是主线程我的线程ID为:%d\n",tid[i]);
        
    }
    for(int i = 0;i < 4;i++)
    {
        void* ret = NULL;
        pthread_join(tid[i],&ret);
        printf("tid[%d]:%d,返回值为:%d\n",i,tid[i],ret);
    }
    return 0;
}

在这里插入图片描述
这时,整个进程都会随之退出

线程终止

线程终止的出现主要有这几种作用:

  • 资源释放:当线程不再需要某些资源时,可以在线程结束时终止线程,以释放这些资源。
  • 错误处理:线程中发生错误或异常时,可以选择终止线程以防止错误的扩散或损坏其他资源。
  • 完成任务:线程执行完任务后,可以选择终止线程以避免不必要的执行。
  • 优雅退出:在某些情况下,终止线程可能是一种优雅的退出方式。

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

  • 从线程函数return返回
  • 线程可以自己调用pthread_exit函数终止自己
  • 一个线程可以调用pthread_cancel函数终止同一进程中的另一个线程

pthread_exit函数

pthread_exit函数的功能就是终止线程,pthread_exit函数的函数原型如下:

void pthread_exit(void *retval);

参数说明:

  • retval:线程退出时的退出码信息
void* func(void* arg)
{
    char* msg = (char*)arg;
    int cnt = 6;
	while (cnt <= 6){
		printf("I am %s,ID:%d,pid:%d,ppid:%d\n", msg,pthread_self(),getpid(),getppid());
		sleep(1);
        cnt++;
	}

    pthread_exit((void*)4444);

    return (void*)6666;
}

int main()
{
    pthread_t tid[4];
    char str[125];
    for(int i = 0;i < 4;i++)
    {
        sprintf(str,"thread: %d",i);
        pthread_create(tid + i, NULL, func, str);
        printf("我是主线程我的线程ID为:%d\n",tid[i]);
        sleep(1);
        
    }
    for(int i = 0;i < 4;i++)
    {
        void* ret = NULL;
        pthread_join(tid[i],&ret);
        printf("tid[%d]:%d,返回值为:%d\n",i,tid[i],ret);
        sleep(1);
    }
    int c = 5;
    while(c--)
    {
        printf("我是主线程,我还在运行\n");
    }
    return 0;
}

在这里插入图片描述

pthread_cancel函数

线程是可以被取消的,我们可以使用pthread_cancel函数取消一个线程,也可以通过次函数取消主线程,或者取消本线程

pthread_cancel函数原型如下:

int pthread_cancel(pthread_t thread);

参数说明:

  • thread:被取消线程的ID

返回值说明

  • 线程取消成功返回0,失败返回错误码
void *threadRun(void *args)
{

    const char *name = static_cast<const char *>(args);
    int cnt = 5;
    while (cnt)
    {
        cout << name << "   is running  " << cnt-- << "  obtain self id:" << pthread_self() << endl;
        sleep(1);
    }
    pthread_cancel(pthread_self());
    sleep(10);
    pthread_exit((void*)111);
}

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

    void* ret = nullptr;
    pthread_join(tid,&ret);
    cout << " new thread exit : " << (int64_t)ret << "   quit thread: " << tid << endl;
    return 0;
}

在这里插入图片描述
在终止程序之前,将线程退出。(取消线程的方式也可以放到主线程中)

分离线程
  • 默认情况下,我们一般都是阻塞式等待线程退出,然后主线程回收他,如果不使用pthread_join函数,就会造成无法释放资源,造成内存泄漏。
  • 但是如果我们不担心返回值,我们可以直接将线程进行分离,后序当线程进行退出的时候,就会自动退出并且释放。
  • 线程分离后,不代表的他和进程没有关系了,它们同样都是复用关系,它的异常出错终止也是影响整个进程的,只不过他只是不需要手动pthread_join了
  • joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

一个线程如果被分离,就无法在被join,如果ioin就会函数报错
分离线程的函数叫pthread_detach

pthread_detach函数的函数原型如下:

int pthread_detach(pthread_t thread);

参数说明:

  • thread:被分离线程的ID。
    返回值说明:
  • 线程分离成功返回0,失败返回错误码。
void *threadRun(void *args)
{
    pthread_detach(pthread_self());
    const char *name = static_cast<const char *>(args);
    int cnt = 6;
    while (cnt)
    {
        cout << name << "   is running  " << cnt-- << "  obtain self id:" << pthread_self() << endl;
        sleep(1);
    }

    pthread_exit((void *)111);
}

int main()
{
    pthread_t tid[4];
    char str[128];
    for (int i = 0; i < 4; i++)
    {
        sprintf(str,"thread:%d",i);
        pthread_create(tid + i, nullptr, threadRun, str);
        sleep(1);
    }
    sleep(10);
    printf("全部回收\n");
    return 0;
}

在这里插入图片描述

线程ID及进程地址空间布局
  • pthread_create函数产生的线程ID,存放在第一个参数指向的地址中,该线程的ID和内核中的LWP不是一回事。
  • pthread_create函数第一个参数指向一个虚拟内存单元,线程库的后续操作就是根据该线程ID来操作线程的。

pthread_t到底是什么类型呢?

首先,Linux不提供真正的线程,只提供LWP,也就意味的操作系统只需要对内核执行流LWP进行管理,而供用户使用的线程接口等其他数据,应该由线程库自己来管理,因此管理线程时的“先描述,再组织”就应该在线程库里进行。

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

在这里插入图片描述

进程运行时动态库被加载到内存,然后通过页表映射到进程地址空间中的共享区,此时该进程内的所有线程都是能看到这个动态库的。
在这里插入图片描述

我们说每个线程都有自己私有的栈,其中主线程采用的栈是进程地址空间中原生的栈,而其余线程采用的栈就是在共享区中开辟的。除此之外,每个线程都有自己的struct pthread,当中包含了对应线程的各种属性;每个线程还有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据。
每一个新线程在共享区都有这样一块区域对其进行描述,因此我们要找到一个用户级线程只需要找到该线程内存块的起始地址,然后就可以获取到该线程的各种信息。

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

pthread_t到底是什么类型取决于实现,但是对于Linux目前实现的NPTL线程库来说,线程ID本质就是进程地址空间共享区上的一个虚拟地址,同一个进程中所有的虚拟地址都是不同的,因此可以用它来唯一区分每一个线程。

例如,我们也可以尝试按地址的形式对获取到的线程ID进行打印。

void* func(void* args)
{
    while (1)
    {
        printf("new thread tid:%p\n",pthread_self());
        sleep(1);
    }
    
    
}

int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,func,NULL);
    while(1)
    {
        printf("main thread tid:%p\n",pthread_self());
        sleep(1);
    }

    void* ret;
    pthread_join(tid,&ret);
    return 0;
}

在这里插入图片描述
证明一人一块空间,都有自己独立的线程栈和线程局部存储部分。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值