Go语言模型:Linux线程调度 vs Goroutine调度

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

本文链接:https://blog.csdn.net/thisinnocence/article/details/80474698

调度本质上体现了对CPU资源的抢占。调度的方式可以分为:

抢占式调度。依赖的是中断机制,通过中断抢回CPU执行权限然后进行调度,如Linux内核对线程的调度。

协作式调度。需要主动让出CPU,调用调度代码进行调度,如协程,没有中断机制一般无法真正做到抢占。

Linux NPTL 线程库

看操作系统方面的文章时,要注意区分其描述的是通用操作系统还是某种特定的操作系统(如: Windows/Linux/macOS),如果是某种具体的操作系统的实现,还要看其基于哪个版本(如Linux2.6前后的线程模型就有变化),因为操作系统是在不断完善和演进的。

NPTL(Native POSIX Thread Library)是Linux 2.6引入的新线程库,底层调用的内核优化过的clone系统调用。NPTL最初由Red hat开发,替代的是Linux 2.6版本以前的LinuxThreads,更加符合POSIX标准,可以更好的利用多核并行(parallel)运行。这里注意并发(parallel)和并行(concurrency)的区别: 并发可能是对CPU单核的分时复用,并行是真正的CPU多核同时执行。

NPTL线程库的一个设计理念就是用户级线程和内核级线程是1:1的关系,调度完全依赖内核,这样可以充分利用多核。NTPL最初被提出时的一篇文章这样阐述了这个问题,(当然这篇文章时间较早,不能好很好的描述最新实现了,文章开头也有提到),不过设计的思路还是可以学习思考的,后续的具体实现还要看跟进最新的代码与文档。

The most basic design decision which has to be made is what relationship there should be between the kernel threads and the user-level threads. It need not be mentioned that kernel threads are used; a pure user-level implementation could not take advantage of multi-processor machines which was one of the goals listed previously. One valid possibility is the 1-on-1 model of the old implementation where each user-level thread has an underlying kernel thread. The whole thread library could be a relatively thin layer on top of the kernel functions.

需要注意的是,上面引文中说的用户级线程指的是完全在用户态调度管理的线程,内核级线程则指的是有task_struct与之对应并由内核进行统一管理调度的线程。而现在,用户线程这个术语一般指拥有用户空间的的用户态程序,内核线程则是指完全运行在内核态的常驻系统的线程(如1号init线程)。

Linux 线程调度时机

对于Linux调度,简单来说就是在内核态执行schedule函数,按照一定策略选出这个CPU核接下来要执行的线程,上下文切换到对应线程执行。

对于用户线程调度,首先要切换到内核态,用户栈切到内核栈,在内核态调用schedule函数,选出下一个要被执行的线程,上下文切换,执行。用户线程的调度时机有:

线程运行结束或睡眠,主动进行调度,如:程序运行中调sleep、结束时调用的exit,最终都会调到schedule,进行调度;

调用阻塞的系统调用,陷入内核后会进行调度,如各种阻塞的IO调用;

从系统调用、中断处理返回用户空间的前夕,根据task_struct的need_resched判断是否进行调度,如:从时钟中断返回发现时间片用完,进行调度。

对于内核线程调度,这里的内核线程指运行在内核态的线程。Linux 2.6开始支持内核抢占,如果没有加锁,内核就可以进行抢占。内核线程的调度时机有:

内核线程被阻塞,显示调用schedule进行调度;

从中断返回内核空间前夕,发现内核线程无锁,根据对应need_resched自断判断是否进行调度;

内核代码再一次具有可抢占性的时候;

Linux 线程调度上下文切换

Linux线程调度的上下文切换,主要由函数context_switch完成,主要完成相关寄存器和栈的切换,如果涉及到了进程(进程是资源管理的单位)切换,还会切换页目录进而切换进程地址空间。下面是选自MIT xv6的一幅图:

Goroutine 调度模型

Go语言天然支持并发靠的就是轻量级的goroutine,goroutine算是一种协程,从Go 1.4后默认goroutine栈初始大小为2k,由Go的runtime完全在用户态调度,goroutine切换也不用陷入内核,相对OS线程调度开销很小。但是,物理CPU核只能由内核来调度,内核只能调度由task_struct结构管理的OS线程,这样就涉及到了一个goroutine和OS线程的关联关系,Go 1.1后采用的就是著名的G-P-M模型。Go runtime中有关调度的代码很多都是用汇编语言编写,内核也是如此,看调度的源码细节就需要对栈帧结构、调用约定、内存模型等有一定深度的了解。

G-P-M模型

G是goroutine,P是抽象的逻辑processor,M是操作系统线程Machine。P作为go runtime抽象的处理器,其个数肯定要小于等于物理CPU核数的。具体的描述可以看The Go scheduler这篇文章,阐述的比较深入。总体来说,goroutine是用户态的轻量级的线程,通过Go的runtime来调度获取P,P最终会关联到一个系统线程M上,从而使得这个P下面的goroutine获得执行。也谈goroutine调度器这篇文章中的图很形象:

Goroutine 调度时机

与Linux线程调度相比,Goroutine调度不支持抢占。抢占式调度依赖的是中断机制。不过在Go 1.2后,如果goroutine涉及了函数调用,那么就可以做到一定程度的“抢占”。原理也比较容易理解,如下:

这个抢占式调度的原理则是在每个函数或方法的入口,加上一段额外的代码,让runtime有机会检查是否需要执行抢占度。这种解决方案只能说局部解决了“饿死”问题,对于没有函数调用,纯算法循环计算的G,scheduler依然无法抢占。

实测也是如此,对于无函数调用的死循环goroutine,如果其个数等于当前CPU核数,就会导致所有其他goroutine得不到调度。因为再也没有时机能够运行到go的调度代码了。而对于线程的死循环,会有时钟中断来抢占CPU,从中断返回时会进行调度完成抢占。

总结

Linux2.6后完全支持了内核抢占,并引入了NPTL线程库。Go 1.2后也在一定程度上支持了goroutine的“抢占式”调度。调度的原理细节可以看文末的参考文献。

参考

linux 内核源代码情景分析 (内核v2.4)

The Native POSIX Thread Library for Linux(过时不推荐)

Linux NTPL pthreads

POSIX Threads Programming

The Native POSIX Thread Library for Linux

Linux Threads

深入 Linux 多线程编程

操作系统的基本原理与简单实现

user-level thread --> LWP thread

The Go Programming Language

The Go scheduler

也谈goroutine调度器

Goroutine调度实例简要分析

————————————————

版权声明:本文为CSDN博主「ka__ka__」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/thisinnocence/article/details/80474698