0


【Linux】多线程(一万六千字)

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

世上有两种耀眼的光芒,一种是正在升起的太阳,一种是正在努力学习编程的你!一个爱学编程的人。各位看官,我衷心的希望这篇博客能对你们有所帮助,同时也希望各位看官能对我的文章给与点评,希望我们能够携手共同促进进步,在编程的道路上越走越远!

提示:以下是本篇文章正文内容,下面案例可供参考

线程的概念

  • 线程是进程内部的一个执行分支,线程在进程的地址空间内运行。
  • 线程是CPU调度的基本单位,CPU在调度的时候,会有很多进程和线程混在一起,但是CPU不管这些,在调度的时候,都是让task_struct进行排队的,CPU只调度task_struct,所以说线程是CPU调度的基本单位是对的。

线程的理解(Linux系统为例)

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

  • 正文:代码段(区),我们的代码在进程中,全部都是串行调用的。
  • 就一个进程,正文部分有很多对应的函数,但我们在执行的时候,所有的函数调用都是串行调用的。
  • 比如:main()函数中有a、b、c、d四个函数,我们单进程执行main()函数时,所有的函数都是串行跑的;那么今天我们想办法将代码拆成两部分,a、b函数一部分,c、d函数一部分,让一个执行流执行a、b,让另一个执行流执行c、d函数,如果a、b和c、d函数没有明显的前后关系的话,分成两个执行流让它能跑,那么此时我们的函数调用过程就是并行跑了。

无论是多进程,还是多线程,它的核心思想:把串行的东西变成并行的东西。

在Linux系统里如何保证让正文部分的代码可以并发的去跑呢?

  • 以前:再创建进程PCB、进程地址空间、页表,再从磁盘中向物理内存中加载新的程序,经过新创建进程的页表与物理内存建立映射关系,此时就有了独立的代码和数据,独立的内核数据结构,所以这两个进程是独立的。但是我们发现按照之前的做法,进程创建的成本(时间和空间)是非常高的。
  • 用户想要的是多执行流,所以Linux创建了一个线程,假设正文部分有很多的代码,想办法将代码分为若干份区域,比如三份区域,进程地址空间中的其它区域可以都看到,再创建一个执行流的时候,不用创建地址空间和页表,只需要在地址空间内创建两个新的task_struct,让两个新的task_struct指向同一块进程地址空间,那么它们就能看到同一份地址空间的资源,让A进程用第一个区域,让B进程用第二个区域,让C进程用第三个区域,那么CPU调度的时候,拿着三个task_struct,把当前进程的串行执行的三份代码,变成了并发式执行这三份代码了,所以我们把这种在地址空间内创建的"进程",把它叫做线程。

进程地址空间上布满了虚拟地址,进程地址空间以及上面的虚拟地址的本质是一种资源。

我们之前说的,代码可以并行或并发的去跑,比如:父子进程的代码是共享的,数据写实拷贝各自一份,所以可以让父子执行不同的代码块,这样就可以将代码块进行两个各自调度运行了。

为什么要有多进程呢?

目标不是为了多进程,是为了多执行流并发执行,为了让多个进程之间可以并发的去跑相同或不同的代码。

为什么要这么设计Linux"线程"?

线程跟进程一样,也是要被调度的。
线程在一个进程内部,就意味着一个进程内部可能会存在很多个线程。
如果我们要设计线程,OS也要对线程进行管理!先描述,再组织。描述线程:线程控制块(struct TCB),要保证线程被OS管理,比如用链表将线程管理,还要保证进程PCB和这些线程进行关联,PCB中的对应的指针指向对应的线程,但是这样是非常复杂的。

  • 管理线程的策略和进程是非常像的,OS要求我们对应的线程在进程内运行,是进程内的执行分支,只要符合这个特点,就都是线程,并不一定必须上面的实现。管理进程已经设计数据结构,设计调度算法,还写了创建、等待、终止等各种接口,那么可以把进程的数据结构和调度算法等代码复用起来。
  • Linux的设计者认为,进程和线程都是执行流,具有极度的相似性,没有必要单独设计数据结构和调度算法,直接复用代码。
  • 使用进程来模拟线程。
  • Windows是单独的设计了线程模块。Linux用的是复用进程的代码来设计的。

线程的优点

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

线程的缺点

性能损失

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

健壮性降低

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

缺乏访问控制

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

编程难度提高

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

线程异常

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

线程用途

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

Linux进程 VS 线程

进程和线程

  • 以前的进程:一个内部只有一个线程的进程。
  • 今天的进程:一个内部至少有一个线程的进程。我们以前的讲的进程是今天讲的进程的一种特殊情况。

什么是进程呢?

  • 内核的数据结构+进程的代码和数据(也就是一个或者多个执行流、进程地址空间、页表和进程的代码和数据)
  • 线程(task_struct)叫做进程内部的一个执行分支。
  • 线程是调度的基本单位。
  • 进程的内核角度:承担分配系统资源的基本实体。
  • 不要站在调度角度理解进程,而应该站在资源角度理解进程。

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

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

讲一个故事:

  • 我们的社会就是一个大的系统,在社会中承担分配社会资源(汽车、彩电等)的基本实体是家庭,家庭中的每一个人都是一个执行流,各自都做着不同的事情,但每一个人都会互相协作起来,完成一个公共的事情,把日子过好。家庭中的每一个人就是线程,家庭就是一个进程。

进程的多个线程共享

共享同一地址空间,因此代码段(Text Segment)、数据段(Data Segment)都是共享的:

  • 如果定义一个函数,在各线程中都可以调用;
  • 如果定义一个全局变量,在各线程中都可以访问到;

除此之外,各线程还共享以下进程资源和环境:

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

进程和线程的关系

关于调度的问题

CPU在选择执行流去调度的时候,用不用区分一个进程内部唯一的执行流呢?还是一个进程内部某一个执行流呢?
不需要管。因为线程也有PCB、进程地址空间、页表、进程代码和数据,与进程一致,都是执行流,不需要区分。

  • CPU调度的执行流:线程 <= 执行流 <= 进程
  • Linux是用进程模拟的线程,所以Linux系统里严格意义上讲,不存在物理上的真正的线程,因为没有单独为线程创建struct TCB。
  • Linux中,所有的调度执行流都叫做:轻量级进程。

再次谈谈进程地址空间

多个执行流是如何进行代码划分的?如何理解?

  • 如果进程执行的代码也是一个操作系统,这就相当于使用了进程的壳子,完成了一种内核级虚拟机的技术。
  • OS要不要管理内存呢?大部分OS中的内存在系统角度是以4KB为单位的内存块。
  • 一个可执行程序加载是以平坦模式把整个代码进行编址的,对应的可执行程序也在内部按地址划分成4KB的数据块。
  • 可执行程序里的二进制代码只要写到文件系统里,天然就是4KB的。
  • 从此磁盘和物理内存进行交互时,就以数据块为单位,这就叫做4KB数据块。
  • 内存中的4KB数据块叫做空间;磁盘中文件的4KB数据块叫做内容;所谓的加载,就是将内容放入空间当中,在OS的术语里,一般我们把4KB的空间或内容叫做页框或者页帧。

OS如何管理内存呢?

先描述,再组织!用struct page结构体来描述内存中的4KB数据块,假设有万4GB,4GB内存中有100多个4KB的数据块,用struct page mem[100多万]数组来组织,天然的每一个4KB就有了它的编号(物理地址),编号就是下标,对内存进行管理,就是对该数组的增删查改。未来加载程序时,有多少个4KB的数据块要加载,我们就在内存当中申请多少个数据块,将程序数据块中的内容加载到数组下标中的数据块空间中。

  • OS进行内存管理的基本单位是4KB。
  • 所以以前讲过的父子进程代码共享,数据各自私有一份,内存块中保存的代码,它里面配置的引用计数就是2(父子进程都指向它),所以子进程退出了,不影响父进程,引用计数--;写实拷贝是以4KB为单位进行的,不是只以变量为单位进行的,像new、malloc申请对象的时候,在OS也是以4KB为单位申请空间的。
  • 可执行程序没有被加载之前,就已经有虚拟地址了,加载到物理内存之后,程序内部用虚拟地址,定位我们的程序用物理地址。

以32位平台下为例:
将虚拟地址转换成物理地址:

  1. 虚拟地址(32byte)不是铁板一块,虚拟地址被OS看成10、10、12三个子区域,
  2. OS在进程创建、加载时,根本就不需要搞一个大页表,而只需要,从左往右数的前10个比特位(第一个子区域),这10个比特位从全0到全1的取值范围为:[0,1023]/[0~2^10-1];第二个子区域的范围:[0,1023];
  • 在刚开始创建进程的时候,必须给进程创建页表,这句话没错,但刚开始创建的不是完整的页表,我们只需要创建第一个子区域的页表,页表中有1024个项,查页表时,需要先拿虚拟地址的前10个比特位检索这张表,这张表叫做页目录。
  • 第二个子区域也要创建一张或者多张表,这些表才是页表,我们把页表和页目录里面的条目叫做页表项,页目录里面保存的是二级页表的地址,在查页表时,先拿虚拟地址的前10个比特位做第一张表的索引,再拿虚拟地址的中间10个byte来查页表,OS当中最多会存在1024张页表(不可能的);页表中存放的是物理内存中每一个4KB数据块的起始地址,假设访问一个页表中存放4KB数据块的起始地址0x1234,访问的并不是4KB的数据块,而是访问的是4KB里面的某一个区域或字节,因为线性地址,地址空间的基本单位是1字节的,可以0x1234 + 虚拟地址后的12位(第三个子区域)对应的数据 == 访问到4KB数据块的全部内容。
  • 为什么是12位呢?因为2^12就是4KB。虚拟地址的后12位,我们称之为页内偏移。所以我们查页表只是用虚拟地址的前20位btye。页表里面保存的是页框的物理地址。
  • 页目录占4KB空间,页表最多占4MB空间,所以整个页表内容,我们用4MB就能表示完了。
  • 页表中也可以加一些标志位,表示对应的数据块是内核用的,还是用户用的,还有权限等。

结论:给不同的线程分配不同的区域,本质就是给让不同的线程,各自看到全部页表的子集。就是让不同的线程看到不同的页表。

线程的控制

POSIX线程库

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

创建线程

功能:创建一个新的线程
原型
int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void*
    (*start_routine)(void*), void* arg);
参数
    参数1:输出型参数,创建成功会带出新线程id;
    参数2: 设置线程的属性,attr为NULL表示使用默认属性
    参数3:返回值为void* ,参数为void* 的函数指针,让新线程来执行这个函数方法
    参数4:传递给线程函数的参数,参数会传递到参数3中去
返回值:成功返回0;失败返回错误码

内部创建线程之后,将来会有两个执行流,一个是主线程,一个是新创建的线程,新创建的线程会回调式的调用参数3(函数指针)。

错误检查:

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

// 新线程
void* newthreadrun(void* args)
{
    while (true)
    {
        std::cout << "I am new thread, pid: " << getpid() << std::endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;// 线程id
    // 创建新线程
    pthread_create(&tid, nullptr, newthreadrun, nullptr);

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

PID和LWP

  • ps -aL中的L:能查看真实存在的多线程(轻量级进程)
  • OS在进行调度的时候,用轻量级进程(LWP)的id来进行调度。
  • 单进程和多进程在调度的时候,也在看LWP,因为当每一个进程内部都只有一个执行流时,LWP == PID,此时调用那个都是一样的。
  • LWP和PID说明PCB里面每一个都包含PID,PID表示PCB属于哪一个进程;LWP表明PCB是进程中的那个执行流。
  • getpid()获得进程的pid,而不是获取的是LWP的id,OS没有直接提供获取LWP的id的系统调用。
  • 函数编译完成,是若干行代码块,每一行代码都有地址(虚拟地址/磁盘上-逻辑地址),函数名是该代码块的入口地址。所有的函数,都要按照地址空间统一编址。
  • ps -aL:查看的轻量级进程,所看到的LWP是线程的id,与pthread_create()函数中参数1所得到的新线程的id,两者的表现形式是不同的,因为LWP是在内核当中来标识一个执行流的唯一性的,所以只在OS内使用,但是创建线程pthread_create(),用的线程是属于线程库,所以pthread_create()函数的参数1得到的id是线程库来维护的。这两个id是一对一的,一个是在用户层的,一个是在内核层的。

Linux中有没有真线程呢?

  1. 没有。Linux中只有轻量级进程。
  2. 为了保证自己的纯洁性和简洁性,所以Linux系统,不会有线程相关的系统调用,只有轻量级进程的系统调用。
  3. 为了让用户选择Linux系统,为了让用户能正常的使用对应的线程功能,Linux设计者在用户和Linux系统之间设计了一个中间的软件层,软件层叫做pthread库(原生线程库),任何的Linux内核里面,你在安装的时候,pthread库必须在Linux系统里自带,在系统里默认就装好了,pthread库的作用是将轻量级进程的系统调用进行封装,转成线程相关的接口语义提供给用户,底层其实还是轻量级进程。

用户知道"轻量级进程"这个概念吗?

没有。用户只认进程和线程。其实轻量级进程就是线程。

pthread库不属于OS内核,只要是库就是在用户级实现的,所以Linux的线程也别叫做用户级线程。所以编写多线程时,都必须要链接上这个pthread库:-lpthread

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

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

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

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

线程终止

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

  1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
  2. 线程可以调用pthread_ exit终止自己。
  3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

功能:线程终止
原型
void pthread_exit(void* value_ptr);// 那个线程调用该函数,就终止那个线程
参数
value_ptr: value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

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

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

线程等待

为什么需要线程等待?

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

man pthread_join
int pthread_join(pthread_t thread, void **value_ptr); 等待一个已经结束的线程

  • 参数1:等待指定的一个线程,如果该线程没有退出,会阻塞式等待,若该线程退出了,则返回等待的结果;
  • 参数2:输出型参数,拿到的是新线程对应的返回值

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

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

  1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
  2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数 PTHREAD_ CANCELED,就是-1。
  3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
  4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。

分离线程

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

把一个线程设置成分离:
man pthread_detach
int pthread_detach(pthread_t thread);
参数:要分离哪一个线程的id

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

 pthread_detach(pthread_self()); 

joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

// 同一个进程内的线程,大部分资源都是共享的. 地址空间是共享的!
// 比如:初始化和未初始化区域、还有正文部分没有被线程分走的其它代码也是这个进程所有的线程共享的。
int g_val = 100;

// 将新的线程id转换成16进制的形式
std::string ToHex(pthread_t tid)
{
    char id[64];
    snprintf(id, sizeof(id), "0x%lx", tid);
    return id;
}

 // 线程退出
 // 1. 代码跑完,结果对
 // 2. 代码跑完,结果不对
 // 3. 出异常了 --- 重点 --- 多线程中,任何一个线程出现异常(div 0, 野指针), 都会导致整个进程退出! 
 // ---- 多线程代码往往健壮性不好
 void *threadrun(void *args)
 {
     // 将函数的参数传递过来,字符串的地址来用于初始化新线程的名字
     std::string threadname = (char*)args;
     int cnt = 5;
     while (cnt)
     {
         printf("new thread, g_val: %d, &g_val: %p\n", g_val, &g_val);

         // std::cout << threadname << " is running: " << cnt << ", pid: " << getpid()
         //     << " mythread id: " << ToHex(pthread_self())
         //     << "g_val: "<< g_val << " &g_val: " << &g_val << std::endl;
         g_val++;
         sleep(1);
         // int *p = nullptr;
         // *p = 100; // 故意一个野指针
         cnt--;
     }
     // 1. 线程函数结束 法1:(return)
     // 2. 法2:pthread_exit()
     // pthread_exit((void*)123);// 终止新线程
     // exit(10); // 不能用exit终止线程,因为它是终止进程的.
     return (void*)123; // warning
 }

 // 主线程退出 == 进程退出 == 所有线程都要退出(资源都被释放)
 // 1. 往往我们需要main thread最后结束
 // 2. 线程也要被"wait", 要不然会产生类似进程哪里的内存泄漏的问题(线程是需要被等待的)
 int main()
 {
     // 1. id
     pthread_t tid;// pthread_t就是一个无符号的长整型
     pthread_create(&tid, nullptr, threadrun, (void*)"thread-1");
     // 参数1:输出型参数,得到的是新线程的id   

     // 法3: 
     // 在主线程中,你保证新线程已经启动
     // sleep(2);
     // pthread_cancel(tid);
     // 取消tid线程,那么pthread_join()函数拿到的就是线程的退出码-1,-1就是宏,-1表示这个线程是被取消的

     // 2. 新和主两个线程,谁先运行呢?不确定,由调度器决定
     int cnt = 10;
     while (true)
     {
          std::cout << "main thread is running: " << cnt << ", pid: "
              << getpid() << " new thread id: " << ToHex(tid) << " "
              << " main thread id: " << ToHex(pthread_self())
              << "g_val: "<< g_val << " &g_val: " << &g_val << std::endl;
         printf("main thread, g_val: %d, &g_val: %p\n", g_val, &g_val);

         sleep(1);
         cnt--;
     }
 
     // 如果主线程比新线程提前退出了呢?
     void* ret = nullptr;// void:是不能定义变量的;void*:能定义变量,指针变量是已经开辟了空间的
     // PTHREAD_CANCELED; // (void*)-1
     // 我们怎么没有像进程一样获取线程退出的退出信号呢?只有你手动写的退出码
     // 所等的线程一旦产生信号了,线程所在的进程就被干掉了,所以pthread_join没有机会获得信号。
     // 所以pthread_join()函数不考虑线程异常情况!
     int n = pthread_join(tid, &ret); 
     std::cout << "main thread quit, n=" << n << " main thread get a ret: " << (long long)ret << std::endl;
     return 0;
 }

新线程所产生的异常由父进程去考虑。

  std::string ToHex(pthread_t tid)
 {
     char id[64];
     snprintf(id, sizeof(id), "0x%lx", tid);
     return id;
 }

__thread uint64_t starttime = 100;
 // __thread int tid = 0;
 // 全局变量g_val属于已初始化区域,是所有线程共享的资源
 // __thread:让这个进程中所有的线程都私有一份g_val全局变量
 // __thread:编译器在编译时将g_val变量拆分出来,放到了每个线程的局部存储空间内
 int g_val = 100;

 // 主线程一直在等待新线程,在等待期间,不会创造价值,所以有类似于非阻塞等待:
 // 线程是可以分离的: 默认线程是joinable(需要被等待)的。
 // 如果我们main thread不关心新线程的执行信息,我们可以将新线程设置为分离状态:
 // 你是如何理解线程分离的呢?底层依旧属于同一个进程!只是不需要等待了
 // 一般都希望mainthread 是最后一个退出的,无论是否是join、detach
 void *threadrun1(void *args)
 {
     starttime = time(nullptr);
     // pthread_detach(pthread_self());// 该线程自己分离自己,则主线程不会再等待新线程
     std::string name = static_cast<const char *>(args);

     while(true)
     {
         sleep(1);
         printf("%s, g_val: %lu, &g_val: %p\n", name.c_str(), starttime, &starttime);
     }

     return nullptr;
 }

 void *threadrun2(void *args)
 {
     sleep(5);
     starttime = time(nullptr);

     // pthread_detach(pthread_self());
     std::string name = static_cast<const char *>(args);

     while(true)
     {
         printf("%s, g_val: %lu, &g_val: %p\n", name.c_str(), starttime, &starttime);
         sleep(1);
     }

     return nullptr;
 }

 int main()
 {
     pthread_t tid1;
     pthread_t tid2;
     pthread_create(&tid1, nullptr, threadrun1, (void *)"thread 1");
     pthread_create(&tid2, nullptr, threadrun2, (void *)"thread 2");

     pthread_join(tid1, nullptr);
     pthread_join(tid2, nullptr);
     // pthread_detach(tid);// 可以由主线程来进行使新线程进行分离

     // std::cout << "new tid: " << tid << ", hex tid: " << ToHex(tid) << std::endl;
     // std::cout << "main tid: " << pthread_self() << ", hex tid: " << ToHex(pthread_self()) << std::endl;

     // int cnt = 5;
     // while (true)
     // {
     //     if (!(cnt--))
     //         break;
     //     std::cout << "I am a main thread ..." << getpid() << std::endl;
     //     sleep(1);
     // }

     // std::cout << "main thread wait block" << std::endl;
     // 主线程要等待新线程,否则会出现类似于僵尸进程的问题
     // 若是新线程是分离的状态,等待的话,会出错返回
     int n = pthread_join(tid, nullptr);
     std::cout << "main thread wait return: " << n << ": " << strerror(n) << std::endl;

     return 0;
 }

面试题

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

  • 当一个进程被CPU调度时,CPU内部一定存在非常多的寄存器,寄存器当中保存的都是当前进程运行的上下文数据,比如:把CPU内部寄存器的值保存到PCB当中,下次再调用时,再恢复过来;
  • 寄存器CR3保存了页表的起始地址,CPU要切换一个进程的话,我们只需要把PCB、进程地址空间、页表切换就行了;
  • 今天再加了一个线程,线程也是调度的实体,当切换线程时,CPU内部的寄存器中也会有各种的数据,那么线程切换也要进行上下文的保护,将数据保存到线程PCB当中,需要再恢复出来,线程切换时,进程地址空间和页表不用换;
  • 进程切换的时候,进程地址空间和页表要切换;
  • PCB指向进程地址空间,只要PCB切换了,对应的进程地址空间也就切换了,切换一个页表就是切换了CPU中存储页表地址的寄存器CR3;
  • 两者比较一下:线程切换,不切换页表,进程切换,切换一下页表就可以了;
  • 线程切换需要保存的上下文数据,只比进程少一点。(页表不需要切换 == 寄存器CR3不需要改变)

在CPU内部,每一次我们读取当前进程的代码和数据时,CPU上硬件上有一个cache,cache是一个集成在CPU内部的,一段比寄存器容量大的多的一段缓存区,当CPU将虚拟地址转物理地址,进行寻址的时候,找到物理内存中的代码,只找到了一行代码,但是下一次大概率还需要这一行代码的下一行代码,所以会将这块相关的代码全部搬到CPU内部的cache中缓存起来,从此CPU访问代码数据的时候,不用从内存中读取了,而直接从CPU中较近的cache中读取,从而大大提高CPU寻址的效率。

进程间切换时,假设A进程被切换下去,那么CPU内部cache中的数据就被清空,由新切换上来的B进程来重新填充cache中的代码和数据,这个过程很漫长,因此进程间的切换,成本很高。对于线程切换来说,因为进程地址空间、页表、进程的代码和数据都是共享的,所以CPU中cache的缓存区中的数据不需要被丢弃,所以线程切换的成本要比进程要低。

一组寄存器:

  • 每个线程都是独立的,被单独调度的执行流,每个线程都要有一组自己独立的上下文数据。

线程都有自己的临时变量,在C语言中在函数中的临时变量都是在栈区上保存的,比如:主线程要形成自己的临时变量,新线程也要形成自己的临时变量,函数调用要压栈和出栈,如果两个线程使用的是同一个进程地址空间上的栈区,两个都在访问这个栈区,如果一个栈区被多个线程共享的话,每个线程都要向栈区中压栈入自己的临时数据,那么在栈中压入的临时变量,无法分清是那个线程的,所以库在设计的时候,都必须保证给每个线程都要有自己独立的用户栈。每个线程都有自己独立的栈结构。

哪些属于线程私有的?

  1. 线程的硬件上下文(CPU寄存器的值)(调度)
  2. 线程的独立栈结构(常规运行)

线程共享:

  1. 代码和全局数据;
  2. 进程的文件描述符表

一个线程出问题,导致其它线程也出问题,导致整个进程退出---线程安全问题。

多线程中,公共函数如果被多个线程同时进入---该函数被重入了。

多线程创建

#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <unistd.h>
#include <cstdlib>
#include <pthread.h> // 原生线程库的头文件

const int threadnum = 5;

class Task
{
public:
    Task()
    {}
    void SetData(int x, int y)
    {
        datax = x;
        datay = y;
    }
    // 执行的任务
    int Excute()
    {
        return datax + datay;
    }
    ~Task()
    {}
private:
    int datax;
    int datay;
};

class ThreadData : public Task
{
public:
    ThreadData(int x, int y, const std::string& threadname) :_threadname(threadname)
    {
        _t.SetData(x, y);
    }
    std::string threadname()
    {
        return _threadname;
    }
    int run()
    {
        return _t.Excute();
    }
private:
    std::string _threadname;
    Task _t;
};
// 结果
class Result
{
public:
    Result() {}
    ~Result() {}
    void SetResult(int result, const std::string& threadname)
    {
        _result = result;
        _threadname = threadname;
    }
    void Print()
    {
        std::cout << _threadname << " : " << _result << std::endl;
    }
private:
    int _result;
    std::string _threadname;
};

// 每个线程都会执行这个函数
void* handlerTask(void* args)
{
    ThreadData* td = static_cast<ThreadData*>(args);

    std::string name = td->threadname();

    Result* res = new Result();
    int result = td->run();

    res->SetResult(result, name);

    std::cout << name << "run result : " << result << std::endl;
    delete td;

    sleep(2);
    return res;

    // 这个函数没有使用全局变量,在函数中定义的threadname变量在自己的独立栈上,所以多个线程并不影响
    // 虽然该函数重入了,但是函数并不会出问题
    // // std::string threadname =static_cast<char*>(args);
    // const char *threadname = static_cast<char *>(args);
    // while (true)
    // {
    //     std::cout << "I am " << threadname << std::endl;
    //     sleep(2);
    // }

    // 虽然对于线程来说堆空间是共享的,但是每个线程都只能拿到自己堆空间的起始地址,其它线程的堆空间看不到
    // delete []threadname;

    // return nullptr;
}
// 1. 多线程创建
// 2. 线程传参和返回值,我们可以传递基本信息,也可以传递其他对象(包括你自己定义的!)
// 3. C++11也带了多线程,和我们今天的是什么关系??? 
int main()
{
    std::vector<pthread_t> threads;
    // 创建5个线程
    for (int i = 0; i < threadnum; i++)
    {
        char threadname[64];// 第二次循环时,第一次循环时的缓冲区中的数据就被释放掉或者被后来的数据覆盖
        snprintf(threadname, 64, "Thread-%d", i + 1);// 将线程名为参数传递给线程函数
        // 我们不能让每一个线程的threadname的变量都指向同一块缓冲区,我们要给每一个线程申请一个属于自己的空间
        ThreadData* td = new ThreadData(10, 20, threadname);

        pthread_t tid;
        pthread_create(&tid, nullptr, handlerTask, td);
        threads.push_back(tid);// 将线程id保存到vector中
    }
    std::vector<Result*> result_set;// 结果
    void* ret = nullptr;
    // 循环等待线程
    for (auto& tid : threads)
    {
        pthread_join(tid, &ret);
        result_set.push_back((Result*)ret);
    }

    for (auto& res : result_set)
    {
        res->Print();
        delete res;
    }
}

新线程处于分离状态,新线程无线循环的跑下去,主线程5秒之后,就退出,会发生什么事情呢?

  • 5秒之后,主线程和新线程都会退出。因为主线程(man thread)退出,代表进程结束,那么进程曾经所申请的进程地址空间、页表、代码和数据也会被释放,虽然新线程是分离的,但是依旧是和主线程共享资源的;所谓分离,仅仅是主线程不需要再等待新线程了,不需要关心新线程的执行结果,但资源依旧是共享的。

新线程处于分离状态,新线程无线循环的跑下去,但是新线程中会出现异常,主线程5秒之后,就退出,会发生什么事情呢?

  • 异常之后,整个进程都会退出。

总结

好了,本篇博客到这里就结束了,如果有更好的观点,请及时留言,我会认真观看并学习。
不积硅步,无以至千里;不积小流,无以成江海。

标签: linux 运维 服务器

本文转载自: https://blog.csdn.net/2301_79585944/article/details/140135888
版权归原作者 2301_79585944 所有, 如有侵权,请联系我们删除。

“【Linux】多线程(一万六千字)”的评论:

还没有评论