Go并发编程系列(四) 多线程基本概念与线程模型-张柏沛IT博客

正文内容

Go并发编程系列(四) 多线程基本概念与线程模型

栏目:Go语言 系列:Go并发编程系列 发布时间:2021-02-05 09:32 浏览量:289

多线程

一个进程至少会包含一个线程,因为其中至少会有一个控制流持续运行。因而,一个进程的第一个线程会随着这个进程的启动而创建,这个线程称为该进程的主线程。

当然,一个进程也可以包含多个线程。这些线程都是由当前进程中已存在的线程创建出来的,创建的方法就是调用系统调用即pthread_create 函数。另一方面,线程不可能独立于进程存在。它的生命周期不可能逾越其所属进程的生命周期。

一个进程中的所有线程都拥有自己的线程栈,并以此存储自己的私有数据(这部分数据在线程间是不共享的)。这些线程的线程栈都包含在其所属进程的虚拟内存地址中。

当然,一个进程中也有很多资源会被其中的所有线程共享,这些被线程共享的资源包括在当前进程的虚拟内存地址中存储的代码段、数据段、堆、信号处理函数,以及当前进程所持有的文件描述符,等等。

同一进程中的多个线程运行的一定是同一个程序(文件与代码),只不过具体的控制流程和执行的函数可能会不同。

创建一个新线程,也不会像创建一个新进程那样耗时费力,因为在其所属进程的虚拟内存地址中存储的代码、数据和资源都不需要被复制(新线程的创建比新进程的创建的成本减少在不用让操作系统新开一块内存并初始化进程控制块PCB的信息)。

 

 

 

线程相关概念

 

线程的标识

线程ID或者TID。与进程不同,线程ID在系统范围内可以不唯一,而只在其所属进程的范围内唯一。不过,Linux系统的线程实现则确保了每个线程ID在系统范围内的唯一性,并且当线程不复存在后,其线程ID可以被其他线程复用。

 

 

线程间的控制

同一个进程中的任意两个线程之间的关系都是平等的,它们之间并不存在层级关系。任何线程都可以对同一进程中的其他线程进行有限的管理。这里所说的有限的管理主要有以下4种。

 

创建线程

任何线程都可以通过调用系统调用pthread_create 来创建新的线程

为了言简意赅,自此我把创建其他线程的线程简称为调用线程。调用线程需要给定新线程将要执行的函数以及传入该函数的参数值。

 

终止线程

线程可以通过多种方式终止同一进程中的其他线程。其中一种方式就是调用系统调用pthread_cancel。它会向目标线程发出一个请求,要求它立即终止执行。

但是,该函数只是发送请求并立即返回,而不会等待目标线程对该请求做出响应(非阻塞的)。但是目标线程接收到请求到真正终止需要一段时间,这段时间内该目标线程可能会做一些终止前的准备。

终止后的线程不一定会被回收和释放,除非这是一个已分离的线程。

 

连接已终止的线程

所谓的连接已终止的线程即阻塞调用线程,等待子线程执行完再继续运行(python中的thread.join()方法)

此操作由系统调用pthread_join 来执行(需要传入一个线程ID,该函数一般是由调用线程来调用,调用pthread_join后,调用线程会一直等待传入的线程ID对应的那个线程终止(即等待他的start方法执行完毕或被其他线程终止),并把该线程执行的start 函数的返回值告知调用线程

 

如果目标线程已经处于终止状态,那么pthread_join函数会立即返回,然后调用线程就会执行pthread_join 之后的代码

pthread_join可以帮助僵尸线程得到释放和回收。

 

分离线程

将一个线程分离意味着它不再是一个可连接的线程。而在默认情况下,一个线程总可以被其他线程连接。分离操作的另一个作用是让操作系统内核在目标线程终止时自动进行清理和销毁工作。一个线程即使没有运行结束或者即使没有开始运行也可以被分离。分离操作是不可逆的,不过对于一个已处于分离状态的线程,执行终止操作(pthread_cancel)仍然会起作用。

分离操作由系统调用pthread_detach 来执行,它接受线程ID作为参数。

 

一个线程对自身也可以进行两种控制:终止和分离。

线程终止自身的方式有很多种。比如在线程执行的start 函数中执行return 语句,会使该线程随着start 函数的结束而终止。需要注意的是,如果在主线程中执行了return 语句,那么当前进程中的所有线程都会终止。另外,在任意线程中调用系统调用exit 也会达到这种效果(调用exit就相当于终止整个进程)

还一种终止自身的方式是,显式地调用系统调用pthread_exit 。与执行return 语句或调用exit 函数不同,如果在主线程(或其他线程)中调用pthread_exit 函数,那么只有主线程自己会被终止,而其他线程仍然会照常运行。

 

线程分离自身与分离其他线程的方式并无不同,即调用pthread_detach 函数

 

总结:

一个线程A可以在别的线程发起pthread_cancel时通知其终止。当然线程Astart函数执行到return时也会自动终止。

线程终止仅意味着该线程会停止工作,但不一定会被清理和回收。这里涉及到可连接线程和非可连接线程的概念,一个线程如果是可连接的,那么当这个线程主动终止(即执行到return)或被动终止(被执行了pthread_cancel而终止)后是不会被清理和回收的,除非其调用线程调用了pthread_join等待该子线程终止(即连接操作)才会回收。而如果是非可连接线程(我们也称之为已分离的线程)终止了就会被马上系统回收。

如果一个可连接的线程终止后既不被系统回收,也不继续复用,就会造成资源的浪费还会减少其所属进程的可创建线程数,此时我们称这种线程为僵尸线程。

 

 

 

 

线程的状态

下面是线程的生命周期及其在生命周期内可能处于的状态

线程创建出来之后,就会进入就绪状态。处于就绪状态的线程会等待运行时机。一旦该线程被系统调度并占有CPU,就会由就绪状态转换至运行状态。正在运行的线程可能会由于某些原因阻塞,进而由运行状态转换至睡眠状态。这里可能的原因包括但不限于等待未完成的I/O操作、等待还未接收到的信号、等待获得互斥量(锁),以及等待某个条件变量。当阻塞线程等待的那个事件发生或条件满足时,该线程就会被唤醒。

这时它会从睡眠状态转出,但并不会直接进入运行状态,而是先进入就绪状态并再次等待运行时机。此外,处于运行状态的线程有时也会因CPU被其他线程抢占(即时间片用完)而失去运行时机,从而转回至就绪状态并等待下一个运行时机。

在当前线程return或被其他线程pthread_cancel停止后,当前线程就会试图进入终止状态。如果当前线程之前没有分离过,并且也没有其他线程与它连接,那么当前线程就会进入僵尸状态而非终止状态。当且仅当有其他线程与之连接后,当前线程才会从僵尸状态转换至终止状态,才会被操作系统内核回收。

主线程结束会导致程序结束,如果程序结束(即整个进程结束),所有线程会被马上回收。

 

 

线程的调度

调度器的实时调度和切换给我们一种众多线程被并行运行的幻觉。调度器会把时间划分成极小的时间片并把这些时间片分配给不同的线程,以使众多线程都有机会在CPU上运行。一个线程什么时候能够获得CPU时间,以及它能够在CPU上运行多久,都属于调度器的工作范畴。线程调度(也称为线程间的上下文切换)是一项非常复杂的工作,因此这里只对线程调度的最基本规则和策略进行阐述。

 

线程的执行总是趋向于CPU受限(cpu操作密集型)或I/O受限(io操作密集型)。调度器会依据它对线程的趋向性的猜测把它们分类,并让I/O密集型的线程具有更高的动态优先级以优先使用CPU。因为调度器认为I/O操作往往会花费更长的时间,应该让它们尽早开始执行。在人决定下一个要敲击的按键、磁盘在磁道中定位簇或者网卡从网络中接收数据帧的时候,CPU可以腾出手来为其他线程服务。这些时间已经可以让CPU在等待io完成的过程中做很多事情。

 

 

优先级:线程的优先级分为动态优先级和静态优先级

线程的动态优先级是可以被调度器实时改变的,而与之相对应的线程的静态优先级是不变的。如果应用程序没有显式指定一个线程的静态优先级,那么它将被设定为0

线程的动态优先级就是调度器在其静态优先级的基础上调整得出的。

动态优先级决定了线程运行的先后顺序,静态优先级决定了调度器分配给线程的时间片长短。

 

所有等待使用CPU的线程会按照动态优先级从高到低的顺序排列,并依序放到与该CPU对应的运行队列中。因此,下一个运行的线程总是动态优先级最高的那一个(所以其实线程的动态优先级会在线程运行完自己的时间片后降到最低)。

 

 

每一个CPU的运行队列中都包含两个优先级队列:其中一个用于存放正在等待运行的线程,我们暂且称之为激活的优先级阵列;而另一个则用于存放已经运行过但还未完成的线程(即当次时间片用完,但整个程序还未运行完的线程),暂且称之为过期的优先级阵列。每一个队列是一个数组,数组的每一个元素是一个链表里面放着线程,一个链表只会包含具有相同优先级的线程,而一个线程也只会放到与其优先级相对应的那个链表中。当一个线程放入某个优先级阵列时,它实际上就是放到了与其优先级相对应的那个链表的末尾处。

下一个运行的线程总是会从激活的优先级阵列中选出。如果调度器发现某个线程已经占用了CPU很长时间(该时间只会小于或等于给予该线程的时间片),并且激活的优先级阵列中还有优先级与它相同的线程在等待运行,那么调度器就会让那个等待的线程在CPU上运行,而被换下的线程会排入过期的优先级阵列。

当激活的优先级阵列中没有待运行的线程时,调度器会把这两个优先级阵列的身份互换,即之前的激活的优先级阵列成为新的过期的优先级阵列,而之前的过期的优先级阵列则会成为新的激活的优先级阵列。如此一来,之前被放入过期的优先级阵列的线程就又有机会运行了。

 

当然,线程不会总是在就绪状态和运行状态之间徘徊,它还有可能被阻塞而进入睡眠状态。处于睡眠状态的线程不能够被调度和运行。换句话说,它们会从运行队列中移除(不在上面2个队列的任何一个中)然后被放到第三个队列:“等待队列”。线程的睡眠状态也可以细分为可中断的睡眠状态和不可中断的睡眠状态。不过,这里并不打算区分它们,相信你对这些已经有所了解了。

线程会因等待某个事件或条件的发生而加入到对应的等待队列中,并随即进入睡眠状态。当事件发生或条件满足时,内核会通知对应的等待队列中的所有线程,这些线程会因此而被唤醒并从等待队列转移至适当的运行队列中。调度器往往会稍稍调高被唤醒的线程的动态优先级(即放在运行队列稍微靠左的元素所在的链表中)。这算是一个小小的额外恩惠,以使这类线程能够更早运行。

如果当前计算机上有多个CPU,那么平衡它们之间的负载也将会是调度器的职责之一。调度器会尽量使一个线程在一个特定的CPU上运行(即一个线程如果一开始是在1CPU上运行的话,当发生线程切换的时候,调度器尽可能的还是让这个线程在1CPU继续运行,而不要被切换到其他CPU运行)。这有很多好处,比如维持高速缓存的高命中率以及高效使用就近的内存,等等。然而有时候,一些CPU过于忙碌,另一些CPU则被闲置。在这种情况下,调度器会把一些原本在较忙碌CPU上运行的线程迁移至其他较空闲的CPU上运行以做到CPU的负载均衡。由于内核会为每个CPU建立一个运行队列,所以线程的这种迁移并不困难。事实上,每个运行队列中都会保存对应CPU的负载系数,调度器可以根据这一系数了解并调整各个CPU的负载。当然,CPU负载平衡的调度逻辑相当复杂,负载系数仅仅是冰山一角。由于篇幅原因,我只介绍这么多。

总体来说,操作系统内核的调度器就是使用若干策略对众多线程在CPU上的运行进行干涉,以使得操作系统中的各个任务都能够有条不紊地进行,同时还要兼顾效率和公平性。

 

 

 

线程实现模型

线程的实现模型主要有3个,分别是:用户级线程模型、内核级线程模型和两级线程模型。它们之间最大的差异就在于用户线程与内核调度实体(Kernel Scheduling Entity,简称KSE)之间的对应关系上。顾名思义,内核调度实体就是可以被内核的调度器调度的对象。在很多文献和书中,它也称为内核级线程,是操作系统内核的最小调度单元。下面我们就来说说这3个线程实现模型的特点以及优劣。

 

用户级线程模型

此模型下的线程是由用户级别的线程库全权管理的。线程库并不是内核的一部分,而只是存储在进程的用户空间之中,这些线程的存在对于内核来说是无法感知的。显然,这些线程也不是内核的调度器的调度对象。对线程的各种管理和协调完全是用户级程序的自主行为,与内核无关(这种用户级线程其实就是协程)

应用程序在对用户级线程进行创建、终止、切换或同步等操作的时候,并不需要让CPU从用户态切换到内核态。从这方面讲,用户级线程模型确实在线程操作的速度上存在优势。并且,由于对线程的管理完全不需要内核的参与,所以使得程序的移植性更强一些。但是这一特点导致在此模型下的多线程并不能够真正并发运行(这就很像coroutine协程)。例如,如果线程在I/O操作过程中被阻塞,那么其所属整个进程也会被阻塞。这正是由线程无法被内核调度造成的。在调度器的眼里,进程是一个无法再被分割的调度单元,无论其中存在多少个线程。另外,即使计算机上存在多个CPU,进程中的多个线程也无法被分配给不同的CPU运行。对于CPU的负载均衡来说,进程的粒度太粗了。因而让不同的进程在不同的CPU上运行的意义也微乎其微。显然,线程的所谓优先级也会形同虚设。同一个进程中所有线程的优先级只能由该进程的优先级来体现。同时,线程库对线程的调度完全不受内核控制,它与内核为进程设定的优先级是没有关系的。正因为用户级线程模型存在这些严重的缺陷,所以现代操作系统都不使用这种模型来实现线程。但是,在早期,以这种模型作为线程实现方式的案例确实存在。由于包含了多个用户级线程的进程只与一个KSE内核级线程相对应,因此这种线程实现模型又称为多对一(M1)的线程实现(对于这句话我的理解是,多个用户级线程或者说协程对应着一个KSE内核级线程, 由于只有该进程只有1个内核级线程,所以任意一个用户级线程的阻塞都会导致这1个内核级线程阻塞,从而导致整个进程阻塞。python中的coroutine协程就是这种情况,但是python asyncio包可以通过事件循环和多路复用解决这个问题)

 

内核级线程模型

该模型下的线程是由内核负责管理的,它们是内核的一部分。应用程序对线程的创建、终止和同步都必须通过内核提供的系统调用来完成。进程中的每一个线程都与一个KSE相对应。也就是说,内核可以分别对每一个线程进行调度。由此,内核级线程模型又称为一对一(11)的线程实现。一对一线程实现消除了多对一线程实现的很多弊端,可以真正实现线程的并发运行因为这些线程完全由内核来管理和调度,内核可以在某个线程阻塞时切换到其他线程使得整个进程在不停的运行。当然,如果一个线程与被阻塞的线程之间存在同步关系,那么它也可能受到牵连。同时,内核对线程的全权接管使操作系统在库级别几乎无需为线程管理做什么事情(也就是说用户程序不必实现复杂的调度逻辑,调度逻辑交给内核实现即可)。但是,内核线程的创建会用到更多的内核资源。并且,像创建线程、切换线程以及同步线程这类操作所花费的时间也会更多。如果一个进程包含了大量的线程,那么它会给内核的调度器造成非常大的负担,甚至会影响到操作系统的整体性能。因此,采用内核级线程模型的操作系统对一个进程中可以创建的线程的数量都有直接或间接的限制。尽管内核级线程模型有资源消耗较大、调度速度较慢等缺点,但是与用户级线程的实现方式相比,它还是有较大优势的。很多现代操作系统都是以内核级线程模型实现线程的,包括Linux操作系统。实际上,Linux操作系统的最新线程库实现(NPTL)为最小化内核级线程模型的劣势付出了巨大的努力,这也使得在Linux操作系统中使用线程更加高效。

 

两级线程模型

两级线程模型的目标是取前两种模型之精华,并去二者之糟粕,也称为多对多(MN)的线程实现。与其他模型相比,两级线程模型提供了更多的灵活性。在此模型下,一个进程包含NKSE内核级线程和M个用户程序自己创建的用户级线程,每个内核级线程会管理多个用户级线程,一个内核级线程下的多个用户线程的运行由用户程序进行调度,而该进程下的多个内核级线程由操作系统内核调度这样的设计缺点是使线程的管理工作更加复杂,因为这需要内核和线程库(用户程序)的共同协作。优点是内核资源的消耗才得以大大减少,同时也使线程管理操作的性能提高了不少。因为两级线程模型实现的复杂性,它往往不会被操作系统内核的开发者所采用。但是,这样的模型却可以很好地在编程语言层面上实现。Go语言的并发模型其实就是使用的两级线程模型,而python的多线程编程使用的是内核级线程模型,pythoncoroutine协程使用的是用户级线程模型(当然,我说的是语言层面而非系统层面)。在Go的并发编程模型中,不受操作系统内核管理的独立控制流并不叫作应用程序线程或者线程,而称为goroutine3种线程实现模型如图所示。

上图中 KSE是内核级线程,而“线程1,2,3”是用户级线程。

 

Go天生就是用来编写并发程序的,它完全可以胜任适合采用多线程编程方式的所有应用场景。

 

但是有一点需要提醒大家的是,Go不支持直接操作内核线程,而且也无需直接操作内核线程。 这意味着我们无法在go语言中裸用内核级线程,而只能通过使用goroutine这样的用户级微线程(goroutine协程)间接的但却更高效的使用内核级线程。和go相比较,python既支持裸用内核级线程(multiprocessing包)也支持用户级线程(coroutine协程+asyncio包),但是goroutinecoroutine是有本质区别的,goroutine可以使用到多核,而python的coroutine只能用到单核,单从这点来看,goroutine的性能要比coroutine高。

 

 

多核的并发

下图展示了在单核CPU和多核CPU上运行并发程序的区别。

当在单核CPU上运行多线程程序时,每一时刻CPU只可能运行一个线程。但由于操作系统内核会根据调度策略切换CPU运行的线程,所以一般情况下你会感觉多个线程在同时运行。此处的同时运行实际上只能算作是并发运行这就是上图左半部分展示的含义。在右半部分,在一个双核心的CPU上运行着一个拥有4个线程的进程。其中,线程1和线程2在CPU核心1上运行,线程3和线程4在CPU核心2上运行。在同一时刻,线程1和线程2中只会有一个运行,而线程3和线程4中也只会有一个运行,但是cpu1和cpu2是同时运行的,所以线程1和线程2之间是并发的,但线程1和线程3之间是并行的。并行运行的一个必要条件就是并发运行。

 

如果您需要转载,可以点击下方按钮可以进行复制粘贴;本站博客文章为原创,请转载时注明以下信息

张柏沛IT技术博客 > Go并发编程系列(四) 多线程基本概念与线程模型

热门推荐
推荐新闻