Linux多线程认识

本文详细介绍了Linux线程的概念,包括虚拟地址到物理地址的转换、多线程、Linux进程与线程的区别,以及从内核和CPU角度看线程。接着讨论了Linux线程的创建、异常、终止、等待和分离,并探讨了线程ID的性质。文章还提到了线程的优缺点,如资源利用率高、编程复杂性增加等,并展示了封装线程接口的方法。
摘要由CSDN通过智能技术生成

目录

🐧一、什么是线程

1.1虚拟地址如何转换成物理地址

1.2多线程

1.3Linux进程vs线程

1.4从Linux内核和CPU的角度看线程

🐧二、Linux线程控制

2.1POSIX线程库

2.2线程异常

 2.3线程终止

①exit不可以用来终止线程

②pthread_exit()

③pthread_cancel

2.4线程等待

①pthread_join

②retval

 2.5分离线程

🐧三、线程ID及进程地址空间布局

🐧四、线程的优缺点

4.1线程的优点

4.2线程的缺点

4.3线程用途

🐧五、封装线程接口

 5.1多线程封装接口

🐧一、什么是线程

1.1虚拟地址如何转换成物理地址

在认识线程之前,我们需要先进一步认识地址空间和页表,然后才能对内存资源的认识更加深刻。

如何看待地址空间和页表:

1、地址空间是进程能看到的资源窗口

2、页表决定进程真正拥有资源的情况

3、合理地对地址空间和页表进行资源划分,我们就可以对一个进程所有的资源进行分类。

认识页表就必须知道虚拟地址如何转换成物理地址

其实页表并不是单纯的只有一张表,一边存储虚拟地址,一边存储对应的物理地址。而是分为了页目录和页表项。按照虚拟地址32个bit位,10、10、12的分配规则由虚拟地址转换为物理地址。具体来说:

1、虚拟地址前十个bit位对应的是页目录页目录存储2^10(1KB)个地址

2、页目录的每一个条目对应一张页表项,页表项对应的是虚拟地址11-20这十个bit位,每个页表项有2^10个地址(1KB)

3、页表项中存储的是指定页框的起始物理地址,这个起始物理地址加上虚拟地址的最后12位作为页内偏移量找到物理起始地址,然后根据类型大小从这个物理起始地址处再往后查找

4、拿着页表项的地址找到物理内存,它对应的偏移量是最后12个bit位,正好是2^12个地址,也就是4KB的大小,也恰好是页框的大小

#页表拆解成页目录和页表项的好处

🖊首先我们反过来想,地址空间一共有2^32个地址。如果页表单纯按照虚拟地址和物理地址对应那种方式存储,那么页表需要有2^32个条目,因为每个地址都需要经过页表映射到物理内存。一个条目假设6个字节,简单一计算光保存页表,就需要24GB的空间,显然是极其不合理的。

🖊这种页表的设计好处在于在需要的时候再创建,不需要的时候就不创建。什么意思呢?比如我页目录有几个条目,就相应创建几个页表项,而不是把所有地址的页表项都创建出来!这样一来页表最大占用空间也就是2^20(1MB).

1.2多线程

🎈在一个程序里的一个执行路线就叫做线程(thread).更准确的定义是:线程是"一个进程内部的控制序列"。

🎈一切进程至少都有一个执行线程。

🎈线程在进程内部运行,本质是在进程地址空间内运行。

🎈在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。

🎈透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

🎈地址空间不再独立创建,也不创建页表,而是和父进程共享,指向相同的虚拟内存,通过相同的窗口,让这些线程只需要执行代码的一部分,访问一部分资源

总结来说一句话:只创建pcb,从父进程中分配资源的执行流,称其为线程

1.3Linux进程vs线程

##进程和线程

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

线程是调度的基本单位。

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

线程ID

一组寄存器

errno

信号屏蔽字

调度优先级

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

文件描述符表

每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数)

当前工作目录

用户id和组id

进程和线程的关系如下图:

1.4从Linux内核和CPU的角度看线程

之前认识到进程是承担分配系统资源的基本实体,但是它比较全面,有自己的pcb,虚拟内存和页表。而线程更像是简化版的进程,它只有自己的pcb虚拟内存和页表都是使用进程的。所以说线程也被称为轻量级进程

CPU的角度来说,线程只是CPU调度的基本单位。以前只有进程时因为内部只有一个执行流,所以CPU调度也只调度这一个执行流(线程),而现在一个进程内部可以有多个执行流,他们都是CPU调度的基本单位,他们在运行时会在CPU上切换(时间轮转片)。

而从Linux内核的角度来说,既然线程是轻量级进程,那么还有必要再专门为线程创建一套管理方法吗?很显然没有必要,Linux是用进程pcb来模拟线程的,这样维护成本大大降低,只不过起了一个新的名字TCB(Thread Control Block).

这种管理模式带来的一个缺陷就是OS层面来看只谈线程(轻量级进程),用户(程序员)也只谈线程(轻量级进程),就是说虽然Linux内核为了管理方便,是用pcb那一套管理tcb,但是我们为了方便使用,不想再费力去使用pcb的接口来创建tcb,这样会很乱。所以为了区分他们Linux专门创建了线程库这个第三方库。

轻量级接口指的是clone这个接口。

 借助这一个接口就能实现进程和线程的创建(只需要控制参数)

clone允许创建一个进程,也允许创建一个轻量级进程。关注前两个参数:

它的第一个参数fn是新的执行流要执行的代码,child_stack表示的是子栈。

vfork()也可以创建子进程,它创建的子进程和父进程是共享地址空间的。

🐧二、Linux线程控制

2.1POSIX线程库

与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以"pthread_"开头的

要使用这些函数库,要通过引入头文件<pthread.h>

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

创建线程

功能:创建一个新的线程

原型:

        int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *

(*start_routine)(void*), void *arg);

pthread_t 无符号整数,代表线程id

参数:

        thread:返回线程ID

        attr:设置线程的属性,attrnullptr表示使用默认属性

        start_routine:参数为void*,返回为void*的一个函数指针--回调函数,是个函数地址,线程启动后要执行的函数

        arg:在回调时,会将参数arg传递给start_routine()的参数。

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

错误检查:

传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。

pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回。

pthreads同样提供了线程内的errno变量,以支持其他使用errno的代码。对于phreads函数的错误,建议通过返回值以判定,因为读取返回值要比读取线程内的errno变量的开销更小

  主线程和新线程都在跑,但是注意到虽然有两个线程,但是只有一个进程。

那么怎么查看当前线程呢?

ps -aL 指令:

a:all;

L:查看线程的选项

两个线程的PID相同,但是LWP不同,LWP是什么?LWP(Light Weight Process)轻量级进程ID.

其中,PID和LWP相同的线程就是主线程,而PID和LWP不相同的就是新线程。CPU在调度的时候,是以LWP为标识符表示特定一个执行流的。那以前我们以PID标识进程是否会有问题呢?

 再来分析一下参数。既然第一个参数返回的是线程ID,那么它是否就是LWP呢?不妨验证一下:

通过可以看出,它并不是LWP,而更像是一个地址。它到底是什么,这里保留悬念,之后再谈。

如果创建一批线程,那么会有怎样的表现呢?

 

 这段代码演示,观察到线程是由0到9一个一个创建的。而这是因为创建每个线程后都有sleep,而如果没有sleep呢?

 仅仅去掉sleep,结果就有很大的不同,这是为什么呢?

 不妨分析一下。首先创建的新线程谁先运行这件事是不确定的,完全由调度器说了算。而代码中传输的是缓冲区的起始地址,主线程本身在对缓冲区进行修改。因为每创建一个新线程主线程都没有停下来等新线程调用回调函数获取缓存区数据,而是马不停蹄地创建新线程,重写缓冲区,所以等新线程获取缓存区数据时,拿到的已经是最新的被重写覆盖多次的数据了。而如果加上sleep这一句代码,新线程就有时间获取到缓冲区数据。

也就是说我们上面这样的操作本身是不安全的,存在问题的。

改写一下代码:

因为每个线程的缓冲区都由new创建,不会出现使用char*共享地址的情况,每个进程都有自己独立的buffer。

上面看到大部分资源被线程共享,但线程也是有自己私有的内部属性的,它的tcb属性是私有的,而且有一定的私有上下文结构,并且每一个线程都有自己独立的栈结构等等。很多老铁可能有疑问,栈区只有一个,线程怎么有自己独立的栈结构呢?可以简单编写代码验证。

 如果每个线程没有自己独立的栈结构,那么不同线程中函数内定义的局部变量cnt的地址就是相同的。

 发现cnt的地址是不相同的,不会相互影响。在函数内定义的变量,都叫做局部变量,具有临时性。在多线程情况下没有问题,侧面验证了每个线程都有自己独立的栈结构。

2.2线程异常

 

新线程有异常,而主线程没有异常,但是进程终止,多线程程序一个线程异常会直接影响其他进程的正常运行,多线程的健壮性或鲁棒性较差。

单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。

线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。(这是因为信号是整体发给进程的,一旦线程出现异常,OS识别到硬件报错,发送信号给进程,进程终止,而所有线程的pid都相同,都是进程pid,进程终止意味着所有线程退出)。

 2.3线程终止

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

1、从线程函数return。

2、线程可以调用pthread_exit终止自己

3、一个线程可以调用pthread_cancel终止同一进程中的另一个线程。

①exit不可以用来终止线程

 

 通过演示知道exit()不能用来终止线程,它是用来终止进程的,因为我们只在新线程中写了exit,但是主线程也被终止了,所以exit()不行。任何一个执行流调用exit()都会让整个进程退出

②pthread_exit()

pthread_exit 函数

功能:线程终止

原型: void pthread_exit(void  *retval);

参数: retval:retval不要指向局部变量

返回值:无返回值,跟进程一样,线程结束的时候无法返回它的调用者。

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

③pthread_cancel

pthread_cancel函数

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

原型:int pthread_cancel(pthread_t thread);

参数: thread:线程ID

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

线程可以被cancel,线程被取消的前提是先跑起来。根据线程id向目标线程发送取消请求,一旦线程取消,pthread_join会立马join成功,得到对应的退出结果。

 

 这段代码想看到的结果是一半的线程运行中被取消,一半的线程正常执行结束。而演示也恰验证了,可以看到一半线程被取消拿到退出码-1,而另一半正常运行线程得到返回值100.

2.4线程等待

为什么需要线程等待:

已经退出的线程,其空间没有被释放,仍然在进程的地址空间内,会导致内存泄漏。

创建新的线程不会复用刚才退出线程的地址空间。

①pthread_join

pthread_join

功能:等待线程结束

原型:int pthread_join(pthread_t thread,void** retval);

参数:

        thread:线程ID

        retval:它指向一个指针,后者指向线程的返回值

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

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

1、如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值。

2、如果thread线程被别的线程调用pthread_cancel异常终止,retval所指向的单元里存放的是常数PTHREAD_CALCELED.

3、如果thread线程是自己调用pthread_exit终止的,retval所指向的单元存放的是传给pthread_exit的参数。

4、如果对thread线程的终止状态不感兴趣,可以将nullptr传给retval参数。

演示:

 

 注意没有在线程退出时就释放线程资源,而是在等待成功后,统一释放,这是因为在等待时使用了结构体内的资源

②retval

再来详谈retval这个参数,相信老铁已经发现在

void pthread_exit(void * retval);

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

这两个接口中都有retval这个参数,不过他们代表的含义是不太一样的。线程退出(pthread_exit)的参数retvalreturn的返回值效果是一样的。而线程等待(pthread_join)的参数retval是一个输出型参数,它也是用来获取线程函数结束时返回的退出结果。因为它要带回的结果类型是void*,所以传二级指针

我们可以编写代码来验证,比如我们给每个线程一个编号,然后返回它的编号,查看是否可以带出来编号。

 

 演示看到拿到了返回值。返回的本质是pthread库中有一个类型是void*指针变量,而我们的返回值就写在这个指针变量中。

注意,只要不是局部变量,函数栈帧结束就销毁的,都可以拿到,比如我们也可以拿到堆空间地址的返回值

 

 

 我们看到确实拿到了堆空间的数据,exit_codeexit_result.

 2.5分离线程

🖊默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。

🖊如果不关心线程的返回值,join是一种负担,这个时候,可以告诉系统,当线程退出时,自动释放线程资源。

参数thread是要分离的线程id。 

int  pthread_detach(pthread_t  thread);

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

pthread_detach(pthread_self());

 

pthread_t pthread_self(void);

 上面这段代码有一个细节,就是主线程中加入了sleep(2)这一行代码。是因为新线程和主线程的执行顺序是不确定的,随机的。如果主线程执行过快,率先阻塞等待新线程,那么即便新线程detach成功退出时,主线程也跟着成功等待退出,这样是看不出来现象的

因为joinable和分离是冲突的,一个线程不能既是joinable又是分离的线程可以处于阻塞等待状态被分离而不能处于分离状态被join。所以先对新线程进行detach如果主线程join失败就可说明线程分离是成功的

 但是这种写法有较大的风险,所以分离线程一般由主线程完成

🐧三、线程ID及进程地址空间布局

pthread_t到底是一个什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。为什么这么说呢?因为我们说过虽然线程共享大部分资源,但它还是有自己私有的资源的,有自己的私有栈。那么这部分私有资源在哪里呢?

 其实线程私有资源在共享区动态库中,进程中栈只有一个,主线程使用的是主线程栈也就是进程栈。而其他新线程的栈在库中的TCB中维护的私有栈,每个线程都有自己的栈

线程id究竟是什么?

线程id就是线程在共享区动态库中的起始地址

🖊pthread_create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。

🖊前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。

🖊pthread_create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的进程ID,属于NPTL线程库的范畴。线程库的后序操作,就是根据该线程ID来操作线程的。

🖊线程库NPTL提供了pthread_self函数,可以获得线程自身的ID。

什么是线程的局部存储

 仅仅是加上__threadg_val就不再共享,新线程做修改不影响主线程。添加__thread,可以将一个内置类型设置为线程局部存储这个变量依旧是全局变量,只不过编译时每个线程都会存一份,这份是独属于这个线程的

那么为什么两次地址大小差异很大?因为全局变量的地址是已初始化数据地址,而设置为线程局部存储后,它是在共享区,共享区数据的地址肯定大于已初始化数据段的地址大小

🐧四、线程的优缺点

4.1线程的优点

与进程间切换相比,线程间切换需要OS做的工作要少得多。

为什么呢?

 想说清楚,就要说到硬件级缓存cache,也被称为高速缓存CPU在访问的时候,先在cache中读取,如果cache没有命中,cache再从内存中读取。那么在切换线程pcb时,因为线程几乎共享所有资源,所以cache中缓存的虚拟地址、页表等数据命中率极高,这样就节省了很多工作

而如果是进程,一切换pcb,cache缓存的的虚拟地址、页表等数据全部失效,都需要更换,成本比较高。

进程间切换要切换页表、切换虚拟地址空间、切换pcb、上下文切换;

线程切换不需要切换页表和虚拟地址空间,只需切换pcb和上下文;

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

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

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

🖊能充分利用多处理器的可并行数量。

🖊在等待I/O操作结束的同时,程序可执行其他的计算任务。

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

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

4.2线程的缺点

🖊性能损失

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

🖊健壮性降低

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

🖊缺乏访问控制

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

🖊编程难度提高

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

 4.3线程用途

🖊合理地使用多线程,能提高CPU密集型程序的执行效率。

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

🐧五、封装线程接口

C++中的多线程

C++的多线程在Linux环境中,本质是对pthread库的封装。

它的调用就很简单,是因为它对pthread库做了封装,这份代码是跨平台的。

 

 5.1多线程封装接口

我们可以自己封装一个。

 这段代码还是挺绕的,因为C++和C语言的不兼容所以我们不能直接传函数指针func_,所以我们封装了一个函数start_routine.这个函数不能定义为类内函数,因为会有this指针的影响,所以为static。定义为static类又会带来无法访问类内私有成员变量,所以又封装了一个类Context,在Thread类内将Thread的this指针和args_变量传给Context类。通过Context类来调用函数成功执行

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值