Linux线程(线程控制)

1、如何看待地址空间和页表?

        在讲线程之前,先讲一下页表。在信号里有讲,页表分为用户级页表和内核级页表。页表其实还有很多其他的属性,如是否命中,RWX权限,,是用户的还是内核的,不论是用户级还是内核级页表,用的数据结构都是一样的,页表是需要被操作系统管理起来的,管理的方式就是先建立对应的数据结构再组织。基于之前所讲的知识,我们可以解释为什么运行如下代码会报错。对p解引用再赋值,本质上是找到p所指向的地址,即“hello world”所在的地址(常量区),需要做虚拟地址到物理地址的转化,再查页表中对应物理地址的rwx权限,发现只有r权限,此时地址转化单元MMU(在CPU内),直接将当前行为终止,再向硬件报错,再把硬件报错转化称信号(11号信号段错误),再在合适的时间处理信号,即终止进程。

char* p = "hello world";
*p = 'H';

        地址空间是进程能看到的资源窗口,页表决定进程真正拥有资源的情况,合理地对进程地址空间+页表进行资源划分,我们就可以对一个进程所有的资源进行分类。

        那么页表是如何做到从虚拟地址到物理地址的映射的呢?以32位系统为例,难道页表真的就是一个2^32个条目的数据结构吗?这样的话算上条目中保存的虚拟地址的属性,需要占用的内存资源是相当大的,这显然是不科学的。

        先补充点前置知识,操作系统是将物理内存,划分为一个个大小为4kb的数据页(页框)的,通过构建一个个struct page{},再构建数组struct page mem[]来管理物理内存,每一个page管理4kb大小的物理内存。代码在被编译形成可执行程序的时候,程序内部采用的地址全部都是虚拟地址,其次程序其实也被划分为一个个大小为4kb的区域,称其为页帧,所以程序加载到内存时,也是以4kb为单位搬到物理内存的,就像外设和内存之间进行交互的时候是以4kb为单位进行交互的。

        讲完了磁盘中的可执行程序(文件)以4kb为单位,由页帧加载到页框,现在再回过头讲虚拟地址。在32位系统下,每个虚拟地址有32个比特位,它在转化到物理地址时不是以一个整体进行转化的,而是被拆分成三部分,以10、10、12个比特位进行层层递进式的转化的。而页表也不是一个整体的结构,地址的前10个比特位有2^10种情况,那么也就有一个具有2^10个条目的页目录结构,对应地,地址的前10个比特位就是页目录的索引。后续的十个比特位代表页表的索引,也是2^10个。比如说一个虚拟地址,通过前10个比特位,页目录中的某一个页表,再通过中间10个比特位,找到了物理内存中的页框,最后的12个比特位,2^12byte刚好是4kb,也就能直接锁定到页框中对应地物理地址。换种说法,也就是我们通过前10个比特位,锁定页表,再通过中间10个比特位,锁定物理内存中的页框,最后12个比特位也就是在页框中的偏移量,最终锁定到了指定的地址。

        操作系统对于不需要建立映射关系的地址(比如说程序内没有对这块地址进行访问),也就不会建立页表,解决了内存空间不足的问题。

2、如何理解线程的概念?

        线程是进程内的一个执行流,进程等于内核数据结构(PCB,页表)加进程对应地代码和数据。我们要知道,不同平台的多线程底层的实现策略是不一样的,这里只讲Linux下的多线程底层实现。在创建进程时,系统要为其创建独立的PCB,它具有独立的虚拟地址空间,独立的页表,就算是通过fork()创建子进程,也会通过写时拷贝新建立虚拟地址空间。而创建线程只需要为其创建出独立的PCB,同一进程内的多个线程是指向同一个虚拟地址空间的,同时也共享一份页表,线程由进程为其分配资源。因为我们可以通过虚拟地址空间+页表的方式对进程进行资源划分,单个线程的执行力度,一定要比所谓的进程要细。站在CPU的角度上来看不论一个PCB代表的是一个完整的程序还是一个程序中的一个执行流,都是一样的,它不会感受到PCB之间量级的差别。

        假设在OS中真的要设计专门的“线程”概念,根据前面所讲的,线程是进程内的一个执行流,那么在一个进程内就有可能能存在多个线程,那么系统中就会存在大量的线程,所以OS是一定对创建出的线程做管理的,管理方法就是先描述再组织,为其设计好结构体对象在通过一定的数据结构进行管理。Windows就是这么干的,在PCB结构体里面有一个TCB(thread control block)链表,用来管理线程。在这样的操作系统下,进程彼此之间要维护兄弟关系、父子关系,同时进程内不还要维护线程和线程之间的关系、线程和所处进程之间的关系。所以它的代码逻辑数据结构的设计一定非常复杂,线程与继承之间的耦合程度也会很高。

        一个线程,它被创建的根本目的很简单,就是为了被执行,需要被CPU调度,要被调度就一定存在ID值这样的概念,还会有状态、优先级、上下文、栈结构等等概念,单纯从调度角度来看,线程和进程有很多的 地方是重叠的。所以Linux设计者,并没有对线程做专门的设计,而是直接复用PCB,用描述进程的进程控制块来充当线程。

        再回过头来回答两个问题。(1)怎么从内核视角来理解进程的概念?进程是承担系统分配资源的基本实体。创建进程时,申请的PCB,虚拟地址空间,多个页表(多级页表),还有加载到内存当中的代码和数据,是需要花费CPU的资源来创建并初始化,花费内存资源来保存对应地内核数据结构和代码和数据的,也有花费CPU的IO资源来将数据从外设中载入内存。(2)在Linux中,又该如何理解线程呢?线程是CPU调度的基本单位。一个进程内部可以有多个执行流,也可以有一个执行流,即多线程和单线程。CPU是不对进程或者线程的task_struct进行区分的,线程可以被认为是轻量级进程可以认为,一个进程,包括了它多个的PCB,一个虚拟地址空间,多个页表,以及一系列系统为其分配的资源,而线程知识进程内部的一个PCB和虚拟地址空间与页表之间的一部分映射关系。(图中页表是简化的)

        总结一下。(1)Linux内核中有没有真正意义的线程呢?没有,Linux是用进程PCB来模拟现成的,是一种完全属于自己的一套线程方案。(2)站在CPU的视角,每一个PCB,都可以称之为轻量级进程。(3)Linux线程是CPU调度的基本单位,而进程是承担分配系统资源的基本单位。(4)进程向操作系统申请资源,而线程向进程伸手要资源。(5)好处是什么?简单,维护成本大大降低,可靠高效。(6)缺点是什么?Linux无法直接提供创建线程的系统调用接口,只能提供创建轻量级进程的接口,这个怎么理解?系统提供了一个clone接口,用于创建执行流,在可以通过传参来选择是否构建独立的虚拟地址空间,如果构建了新的虚拟地址空间,就是创建进程,不构建则是创建线程,fork()接口和后面的pthread_create()接口都是对这个clone接口的封装。

        编写代码感受一下线程比进程量级更轻的这种概念。这里需要用到一个pthread_create()接口。功能是创建一个线程,第一个参数是一个输出型参数,用于获取线程id,pthread_t是一个无符号整数,第二个线程属性设置为nullptr,第三个参数是一个函数指针,也就是创建出的线程中执行的代码,第四个参数是发送给新线程函数中的参数,类型为void*。编写下面这段代码用于测试。这个接口既不是语言提供的,也不是操作系统接口,而是由库(用户线程库,也叫原生线程库)提供的,因此编译时需要加上-lpthread选项,代码和运行结果如下。

        可以看到,在程序内有两个死循环在同时跑,因此肯定是有两个执行流的。通过ps ajx | head -1 && ps ajx | grep mythread指令查看mythread的进程信息,我们发现,此时只有一个进程在跑,再通过ps -aL指令查看线程信息,可以发现有两个线程在跑,每个每个线程有其自己的LWP保存其id,其中主线程的LWP等于进程的PID。通过kill -9 PID指令,我们发现两个线程都会退出,这是因为,信号是发送给进程的,进程内的所有线程也会一并退出。CPU调度的时候是以LWP位标识符表示特定的一个执行流。当进程只有一个执行流的时候,PID和LWP是等价的。

        线程一旦被创建,进程内几乎所有的资源都是被线程共享的。全局函数在主线程和新线程中都是可以调用的,全局变量也是都可以访问或者修改的。如一个全局变量int a = 1;在主线程中对其进行修改a = 2;那么新进程在后续对这个a进行访问的时候就为2,在新线程中做修改也是一样的。如果在线程申请了一段堆空间,理论上这个堆空间也是共享的,加载的动态库以及打开的文件描述符也是共享的。因此线程之间的通信其实很容易,定义一个全局的缓冲区,一个往里写,一个从里面读就可以。

        线程也一定要有自己的私有的资源,有哪些呢?(1)PCB属性私有,用于标识每个线程的唯一性。(2)上下文结构私有,线程被创建的目的就是被调度,必然会出现CPU时间片到了但代码没跑完的情况,所以线程一定要有自己私有的上下文结构实现线程之间独立地被调度。(3)独立的栈结构,线程内部的局部变量都是创建在私有的栈结构上的。

        线程的优点(1)创建一个线程的代价比创建一个新进程小得多。第一点,创建进程要闯将PCB创建地址空间,构建页表,加载代码和数据,构建映射关系,必要时还要打开文件或者处理信号,创建线程直接创建PCB再分配资源就醒了。第二点,与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多,对于进程来讲,切换时操作系统需要做的工作:切换页表、虚拟地址空间、PCB、上下文,而切换线程只需要切换PCB、上下文,但这并不是最关键的,页表和虚拟地址空间其实也就是PCB内的一个指针,PCB更换页表和虚拟地址空间其实也就跟着切了,切换的成本并不高。在CPU除了有各种寄存器,还有一个硬件级别的cache(高速缓存),具有数据保存功能,而软件存在一种属性,叫做局部性原理,即当前正在被访问的数据和代码附近的代码有较大的概率被访问到,如程序正在访问第一万行代码,那么一万行附近的代码也有较大概率被访问。一个进程内部在运行的时候,访问的代码和数据其实是预先被放到了cache中,这样CPU在读取时,可以不用访问内存,转而在cache中进行访问,如果cache没有命中,再去内存中进行读取,读取之后,先缓存到cache里,再从cache中读。所以这里CPU在读数据的时候会牵扯到一个是否命中的问题。所以一个已经稳定运行的进程,在CPU的cache里已经缓存了很多的热点数据,也就是被线程共享的热点数据,所以线程在切换时,cache里保存的数据不用切换。切换进程时,a进程对应地数据在cache中缓存了一大堆,切换到b进程时,这些cache里的数据立马失效,新进程由得重新预热。所以线程间切换和进程间切换的成本,主要体现在cache上(2)线程占用的资源比进程要少得多,线程的资源都是进程给的。(3)能充分利用多处理器的并行数量,为了打到这一点,CPU的核数一般决定了线程的个数,CPU的个数直接决定了进程的个数。

        线程的缺点(1)性能损失,一个计算密集型线程往往无法与其他线程共享一个处理器,如果计算密集型线程的数量比可用的处理器多,可能会因为调度开销造成性能损失。(2)健壮性较低,如何理解?一个进程内部假如说有多个执行流,那么就有多个PCB,当一个执行流内部出现异常,比如说除零操作或者对nullptr解引用,那么系统会向这个进程发送信号,即修改具有同一个PID的所有PCB内部的pending位图,从而引起所有执行流终止。(3)编写和调试多线程程序比单线程程序要困难得多。

3、线程的控制

        回过头讲讲pthread_create()函数,传统的一些函数,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。pthreads函数出错时不会设置全局变量errno,而是将错误代码通过返回值返回,来避免由于线程之间缺乏访问控制带来的一些问题。

        先写一段代码感受一下多线程的运行。创建一批线程来执行同一个函数,给每个线程根据编号传入参数。运行结果如下。可以看到,我们创建了十个进程,按理说会打出0~9十个编号,没有看到0~4,怎么回事?这时由于创建新的线程时,后续CPU先调度哪个执行流是随机的,有可能调度新线程,也有可能调度主线程,也许for循环已经走完了,新线程的代码还没有被执行,而传给新线程的参数是一段缓冲区的地址(代码中的namebuffer),在新线程还没有被执行的时候namebuffer就被覆盖式的写入了,后续传入新线程的参数也就被“更改”了。有人可能会问,namebuffer在for循环中不断地被创建与销毁,难道一直是同一块区域,地址不变吗?在循环中这个缓冲区的大小都是一样的,而且在上下文中也没有调用其他函数,因此循环内的整个代码结构式很稳定的,因此这个缓冲区一直都是同一块区域。

        那么应该怎么创建线程来避免这种问题的发生?我们设计一个专门用于保存线程信息的结构体ThreadData,并且在主线程,在一个for循环内在堆上开辟十个ThreadData,这样不会造成上面一样对同一块空间进行写入和释放。操作结果也是正常的。

        接下来再来认识几个关于线程控制的接口。

        线程是如何终止的?有两种情况,第一种是通过返回值,一个线程运行结束函数返回,那么这个线程就终止了。第二种方法是通过pthread_exit()结束进程,注意exit()是不能用于对线程进行终止的,使用exit()会终止整个进程,而pthread_exit()只会终止当前的线程。传入的参数即返回值,与通过返回值终止进程的返回值表达的含义是相同的。

        线程也是要被等待的。如果不等待,会造成类似僵尸进程的问题,内存泄漏。线程等待的作用是获取新线程的退出信息(其实就是获取线程的返回值)以及回收新线程对应地PCB等内核资源,防止内存泄漏,但是无法查看线程状态。对应地,有线程等待的系统接口pthread_join()。pthread_join()的第一个参数是线程id,代表要对哪个线程进行等待,第二个参数是一个输出型参数类型是void**,用于获取线程函数退出时,返回的退出结果。刚好对应线程的返回值类型void*(输出型参数要传入指针,void*的指针类型是void**)。一个线程返回了,它的返回值也是需要保存起来的,这个void*类型的返回值保存在pthread库中,所以pthread_join()接口本质上是去pthread库中获取指定线程的退出信息。

        注意。线程退出是没有退出信号的,线程出异常,收到信号,整个进程都会退出。因此给线程设置退出信号是没有意义的。

        线程终止还有一种方式,线程取消pthread_cancel()接口,一个线程是可以调用这个接口来取消另一个线程的,线程取消的工作通常是由主线程来做,发送线程id来取消指定线程。线程要被取消,前提是这个线程已经跑起来了。一个线程如果是被取消的,那么它的退出码被设置为-1.

        在c++语言层面中也是由能实现多线程的,观察下面这段代码,使用的全都是语言层面c++所提供的接口和变量。如果代码里使用了这些接口,那么必然要包含thread头文件,编译时也需要“-lpthread”链接标识,找到对应地库,这是因为,在Linux下,任何语言要创建多线程,是要通过系统接口进行创建的,必然要使用pthread库,语言层面的接口只是对系统接口的封装,多了一层软件层。

        分离线程

        刚刚讲线程是可以等待的,调用pthread_join()对指定线程进行等待,这个等待是阻塞式的等待。默认情况下,新创建的线程是joinable(一种表示线程需要被等待的状态)的。线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄露。如果不关心线程的返回值,那么join就是一种负担(阻塞式等待期间主线程什么都干不了),我们可以告诉系统,当线程退出时,自动释放线程资源,这种操作就是线程分离。线程分离用到的接口为pthread_detach(),参数为线程的id,这个接口可以在主线程中调用,也可以在新线程内部自己调用,决定要分离哪个线程就传入哪个线程的id。如何在一个新线程内部获取自己的线程id,另一个接口pthread_self(),没有参数,在哪个执行流内部调用,就返回哪个线程的线程id。

        线程被创建出来,默认是joinable状态,是需要等待的,但是如果被设置为分离状态,那么线程就不能被等待了,如果对已经分离的线程进行等待,那么pthread_join()的返回值不是0, 而是对应地错误码。

        用户在创建线程时,在原生线程库中,可能存在多个线程,这个库可以被多个用户同时使用,因此库是要对线程做管理的,用于保存并管理线程id、线程的栈的地址和大小以及其他一些线程属性。这些线程属性都是在pthread库中建立结构体进行管理的,所以我们可以理解为Linux下的线程是库实现了一部分,操作系统实现了一部分。每次创建一个线程,库当中都要创建线程所对应结构体来进行线程的控制,每个结构体对应一个轻量级进程(执行流)。总结一下:Linux中的用户级线程的方案是,用户关心的线程的属性在库中,内核提供线程执行流的调度,在Linux中用户及线程与内核轻量级进程的比率是1:1,关于线程是如何调度、线程执行的上下文由操作系统来完成。

        那么线程的id究竟是什么?pthread_t是一个长整型,如果将其以16进制方式输出,代码和运行结果是这样的,线程的id长得很像一个地址。下面详细讲讲这个地址是什么。

        在地址空间中有一个位于栈和全局数据区之间的区域叫做共享区(mmap区)。pthread库其实是一个磁盘上的文件。一个进程被创建出来,一定是要将pthread库加载到物理内存,再通过页表映射到地址空间的共享区。每一个线程被创建出来,在内核中为其创建LWP,在pthread库中,还要创建描述线程的结构体(TCB),有多个线程时,通过创建结构体数组的方式对线程的信息进行管理,要找到某个线程,只需要找到它对应结构体的起始位置即可。所以上面的pthread_t类型的线程id其实就是库当中的结构体地址。只要有一个线程的id,我们就可以根据这个id(地址值)找到线程的存储以及它的相关属性。以上所讲的所有线程控制的接口,几乎都要传入线程id参数,本质上是由这个参数找到线程在库中的结构体再对其做相关操作。包括线程结束时,线程执行的结果返回值也会被填写到线程对应的库中的结构体对象中,在对线程进行join操作时,也是根据线程id到结构体对象中获取返回值。这也解答了我们之前的问题,一个进程下的所有线程共享一个地址空间,凭什么说线程的栈结构是独立的?线程在pthread库中的结构体有其对应的私有栈,没有一个线程都有。每一个线程,主线程的栈是在进程地址空间上的,其他新线程的栈都在共享区中的线程库里。总结一下,但我们创建线程时,pthread库要先创建对应的线程控制块(TCB),里面有线程的私有栈结构和线程的局部存储,创建好之后,底层继续调用clone(系统调用)。

线程的局部存储:

        我们知道,全局变量是在进程地址的全局数据区的,所有的线程都可以进行访问或者写操作,如果在定义全局变量时加上“__thread”关键字(注意是两个下划线),可以将一个内置类型设置为线程局部存储,这个变量依旧是全局变量,不过在编译的时候给每一个线程都来了一份。这样两个线程在对这个变量进行访问的时候就不会相互影响了,在新线程里这个变量已经被映射进了,线程内的局部存储,已经在进程地址空间的共享区里了。这也是为什么,在不加__thread时查看全局变量地址较低(在全局数据区),加了__thread时地址较高(在共享区,接近栈区)。

  • 22
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Linux线程通信是指在Linux操作系统中,不同线程之间进行信息交流和数据共享的机制。线程通信是多线程编程中非常重要的一部分,它可以实现线程之间的协作和同步。 在Linux中,线程通信可以通过以下几种方式实现: 1. 共享内存:多个线程可以通过共享内存区域来进行数据的读写。线程可以访问同一块内存区域,从而实现数据的共享。需要注意的是,由于多个线程同时访问共享内存可能会导致数据竞争和不一致性问题,因此需要使用互斥锁或其他同步机制来保证数据的一致性。 2. 信号量:信号量是一种用于线程同步的机制,它可以用来控制对共享资源的访问。通过使用信号量,线程可以等待某个条件满足后再继续执行,或者通知其他线程某个条件已经满足。 3. 互斥锁:互斥锁是一种用于保护共享资源的机制,它可以确保在同一时间只有一个线程可以访问共享资源。当一个线程获得了互斥锁后,其他线程需要等待该线程释放锁才能继续执行。 4. 条件变量:条件变量是一种用于线程同步的机制,它可以让线程等待某个条件满足后再继续执行。条件变量通常与互斥锁一起使用,以确保在等待条件时不会发生竞争条件。 5. 管道:管道是一种用于进程间通信的机制,但在Linux中也可以用于线程间通信。通过管道,一个线程可以将数据写入管道,另一个线程可以从管道中读取数据。 6. 消息队列:消息队列是一种用于进程间通信的机制,但在Linux中也可以用于线程间通信。通过消息队列,一个线程可以将消息发送到队列中,另一个线程可以从队列中接收消息。 7. 套接字:套接字是一种用于网络通信的机制,但在Linux中也可以用于线程间通信。通过套接字,不同线程可以通过网络协议进行通信。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值