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

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

目录

文章目录

前言

线程的概念

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

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

为什么要有多进程呢?

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

线程的优点

线程的缺点

线程异常

线程用途

Linux进程 VS 线程

进程和线程

进程的多个线程共享

进程和线程的关系

关于调度的问题

再次谈谈进程地址空间

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

OS如何管理内存呢?

线程的控制

POSIX线程库

创建线程

PID和LWP

Linux中有没有真线程呢?

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

线程终止

线程等待

分离线程

面试题

多线程创建

总结



前言

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


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

线程的概念

  • 线程是进程内部的一个执行分支,线程在进程的地址空间内运行。
  • 线程是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秒之后,就退出,会发生什么事情呢? 

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

总结

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/768432.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

CVD-Risk-Prevent 个性化心血管健康推荐系统:基于医学指南的规则框架与 LLM 的结合

CVD-Risk-Prevent 个性化心血管健康推荐系统&#xff1a;基于医学指南的规则框架与 LLM 的结合 提出背景推荐算法的选择选择疑问健康指标管理心血管风险因素目标设定实现目标的计划推荐的多维性 算法关键点&#xff1a;如何将心血管健康指标转换为多维推荐&#xff1f;确定风险…

antfu/ni 在 Windows 下的安装

问题 全局安装 ni 之后&#xff0c;第一次使用会有这个问题 解决 在 powershell 中输入 Remove-Item Alias:ni -Force -ErrorAction Ignore之后再次运行 ni Windows 11 下的 Powershell 环境配置 可以参考 https://github.com/antfu-collective/ni?tabreadme-ov-file#how …

【操作系统】进程管理——调度基础(个人笔记)

学习日期&#xff1a;2024.7.3 内容摘要&#xff1a;调度的概念、层次&#xff0c;进程调度的时机&#xff0c;调度器和闲逛进程&#xff0c;调度算法的评价指标 调度的基本概念 有一堆任务需要处理&#xff0c;但由于资源有限&#xff0c;有的事情不能同时处理&#xff0c;这…

Django学习第三天

python manage.py runserver 使用以上的命令启动项目 实现新建用户数据功能 views.py文件代码 from django.shortcuts import render, redirect from app01 import models# Create your views here. def depart_list(request):""" 部门列表 ""&qu…

什么牌子的充电宝最好耐用?多款热门无线磁吸充电宝推荐

在现代生活中&#xff0c;手机、平板等电子设备已成为我们日常工作的必需品&#xff0c;而充电宝则是这些设备的续航神器&#xff01;无论是长途旅行、外出办公&#xff0c;还是日常通勤&#xff0c;一个耐用且高效的充电宝都是必不可少的选择。然而&#xff0c;市场上充电宝品…

如何选择适合自己的虚拟化技术?

虚拟化技术已成为现代数据中心和云计算环境的核心组成部分。本文将帮助您了解如何选择适合自己需求的虚拟化技术&#xff0c;以实现更高的效率、资源利用率和灵活性。 理解虚拟化技术 首先&#xff0c;让我们了解虚拟化技术的基本概念。虚拟化允许将一个物理服务器划分为多个虚…

探讨命令模式及其应用

目录 命令模式命令模式结构命令模式适用场景命令模式优缺点练手题目题目描述输入描述输出描述题解 命令模式 命令模式是一种行为设计模式&#xff0c; 它可将请求转换为一个包含与请求相关的所有信息的独立对象。 该转换让你能根据不同的请求将方法参数化、 延迟请求执行或将其…

玩玩快速冥(LeetCode50题与70题以及联系斐波那契)

一.算法快速幂 今天刷到两个题,比较有意思,还是记录一下. 先来讲讲50题. LeetCode50(Pow(x,n)) 实现 pow(x, n) &#xff0c;即计算 x 的整数 n 次幂函数&#xff08;即&#xff0c;xn &#xff09;。 这道题一看很平常啊,不就一直乘嘛,循环走一次就够了.但是很抱歉,单纯的想…

ArcTs布局入门04——相对布局 媒体查询

如果你也对鸿蒙开发感兴趣&#xff0c;加入“Harmony自习室”吧 扫描下面的二维码关注公众号。 本文将探讨相对布局与媒体查询&#xff0c;为啥把他们放到一起呢&#xff1f;主要是因为相对布局在响应式的场景下做得不太好&#xff0c;一般情况下和媒体查询&#xff08;不同尺…

移动智能终端数据安全管理方案

随着信息技术的飞速发展&#xff0c;移动设备已成为企业日常运营不可或缺的工具。特别是随着智能手机和平板电脑等移动设备的普及&#xff0c;这些设备存储了大量的个人和敏感数据&#xff0c;如银行信息、电子邮件等。员工通过智能手机和平板电脑访问企业资源&#xff0c;提高…

zed_ros2_wapper colcon 报错

问题一&#xff1a; CMake Error at CMakeLists.txt:129 (find_package): By not providing “Findnmea_msgs.cmake” in CMAKE_MODULE_PATH this project has asked CMake to find a package configuration file provided by “nmea_msgs”, but CMake did not find one. Co…

jdk17卸载后换jdk1.8遇到的问题

过程&#xff1a; 1、找到jdk17所在文件夹&#xff0c;将文件夹进行删除。&#xff08;问题就源于此&#xff0c;因为没删干净&#xff09; 2、正常下载jdk1.8&#xff0c;按照网上步骤配置环境变量&#xff0c;这里我参考的文章是&#xff1a; http://t.csdnimg.cn/Svblk …

乘用车副水箱浮球式液位计传感器

浮球式液位计概述 浮球式液位计是一种利用浮球在液体中浮动的原理来测量液位的设备&#xff0c;广泛应用于各种工业自动化控制系统中&#xff0c;如石油化工、水处理、食品饮料等行业。它通过浮球的上下运动来测量液位的高低&#xff0c;具有结构简单、安装方便、测量范围广、…

[Leetcode 136][Easy]-只出现一次的数字

目录 题目描述 具体思路 题目描述 原题链接 具体思路 ①首先看到数组中重复的数字&#xff0c;想到快慢指针&#xff0c;但是数组的元素是乱序的不好求。因此先对数组排序。使用了STL库的sort函数&#xff0c;时间复杂度O(nlogn)不符合题目要求&#xff0c;空间复杂度O(1)。…

大陆ARS548使用记录

一、Windows连接上位机 雷达是在深圳路达买的&#xff0c;商家给的资料中首先让配置网口&#xff0c;但我在使用过程中一直出现无法连接上位机的情况。接下来说说我的见解和理解。 1.1遇到的问题 按要求配置好端口后上位机无连接不到雷达&#xff0c;但wireshark可以正常抓到数…

ESP32-C3模组上跑通MQTT(6)—— tcp例程(1)

接前一篇文章:ESP32-C3模组上跑通MQTT(5) 《ESP32-C3 物联网工程开发实战》 一分钟了解MQTT协议 ESP32 MQTT API指南-CSDN博客 ESP-IDF MQTT 示例入门_mqtt outbox-CSDN博客 ESP32用自签CA进行MQTT的TLS双向认证通信_esp32 mqtt ssl-CSDN博客 特此致谢! 本回开始正式讲…

上海站圆满结束!MongoDB Developer Day深圳站,周六见!

在过去两个周六的北京和上海 我们见证了两站热情高涨的 MongoDB Developer Day&#xff01; 近200位参会开发者相聚专业盛会 经过全天的动手实操和主题研讨会 MongoDB技能已是Next Level&#xff01; 最后一站Developer Day即将启程 期待本周六与各位在深圳相见&#xff0…

线程池666666

1. 作用 线程池内部维护了多个工作线程&#xff0c;每个工作线程都会去任务队列中拿取任务并执行&#xff0c;当执行完一个任务后不是马上销毁&#xff0c;而是继续保留执行其它任务。显然&#xff0c;线程池提高了多线程的复用率&#xff0c;减少了创建和销毁线程的时间。 2…

创建kset

1、kset介绍 2、相关结构体和api介绍 2.1 struct kset 2.2 kset_create_and_add kset_create_and_addkset_createkset_registerkobject_add_internalkobject_add_internal2.3 kset_unregister kset_unregisterkobject_delkobject_put3、实验操作 #include<linux/module.…

代码随想录第42天|动态规划

198.打家劫舍 参考 dp[j] 表示偷盗的总金额, j 表示前 j 间房(包括j)的总偷盗金额初始化: dp[0] 一定要偷, dp[1] 则取房间0,1的最大值遍历顺序: 从小到大 class Solution { public:int rob(vector<int>& nums) {if (nums.size() < 2) {return nums[0];}vector&…