本文将尝试较为深入的谈谈并发编程在Java、Go语言中的体现方式,目的是从一个技术发展的历史趋势上看某一个“新兴”的技术栈出现的前因后果,既加深对某项东西的理解,也是加深在这个行业的“纵向积累”。
进程与线程的基本概念
首先我们思考两个问题:
- 为什么要有进程/线程?
- 进程和线程的区别是什么?
我们知道,在计算机中,任何时刻“都”同时需要处理多个任务: 读取外设的输入、读取磁盘、在屏幕上显示当前任务的结果、后台对某些东西做计算等等。如果说我们的计算机仅仅支持“同时”运行一件事情(这里的同时是相对于用户来说,不是指时刻上的概念),那么我们的计算机将是这个样子: - 键盘在等待输入时计算机不会做任何响应,必须等待键盘有输入时才可以
- 读取磁盘内容时不能在键盘上有输入-因为这时没有运行外设读取逻辑
- 程序在执行自身逻辑,比如计算时,也不能做上述任何操作
毫无疑问,这种情况的计算机从实用的角度是很难受的。并且,CPU由于硬件的原因,它的执行速度比很多外设-磁盘、键盘、网络等快很多个数量级,在进行这些操作时CPU一直等待也是十分浪费资源的。于是便有了”进程“这一抽象模型。概况的讲,有这些特点:
- 正在执行的程序的”整体“,或者说叫实例
- 进程的最主要目地是把某些操作或者逻辑归为一个“整体”,但不一定是同时在运行
也就是说,进程是一组执行资源的组合,进程间的区别是他们彼此“完全独立”,通常的,不考虑他们之间需要通信(因为设计原则就是独立)。进程是一个逻辑上的执行体,它们可能在CPU上的某一个“核”上通过时间片的方式“交替运行”,也可能就是“物理上的并行执行”。而进程的切换则是这样的:
- 当前进程在运行中,主动进行系统调用切换进程或者说被内核的调度程序睡眠
- 内核保存该进程的所有信息, 典型的,程序计数器PC, 所有的寄存器(因为内核不知道哪些正在使用),刷新
MMU(虚拟地址翻译为物理地址的硬件)
- 还原某个新的进程的所有寄存器,切换到新的进程上执行
这里要注意到,进程的切换(也就是所谓的上下文切换)是在内核中,用户空间/内核切换需要开销,保存进程的所有寄存器是一个很大的开销,同时,还有还原另外一个进程的寄存器、MMU
刷新的开销。所以说,进程的切换是非常昂贵。
在这种情况下,对于很多应用场景,“进程”依然是一个比较重量级的操作。于是,产生了“线程”这一概念。线程与进程的主要区别是:
- 同一个进程的所有线程共享相同的地址空间
使得上下文切换时不必刷新
MMU
- 切换线程依然需要切换线程自身的栈空间
因为线程设计为有自己的单独的执行流程,所以需要保存线程栈
一种看待进程和线程的观点是,进程是一组资源的组合(包括打开的文件、虚拟地址空间、子进程、定时器等),线程则是一个”具体“的执行实体,每个线程有自己的寄存器(用来保存线程当前的工作变量),还有自己的堆栈(保存了当前线程的方法调用过程)。在同一个进程中并行运行多个线程,也就是对在同一台计算上并行运行多个进程的模拟。前一个情况下,多个线程共享虚拟地址空间和其他资源,后一种情形下,多个进程共享物理内存,磁盘,打印机和其他资源。
线程的切换与进程的切换一样,都需要操作系统和硬件(CPU, MMU
,内存)的紧密合作,自然这部分是实现在内核中。但是,他们的最终目地都是供”用户空间“使用,所以,线程的具体实现依然是多有考量:
线程的实现方式
线程的实现通常来说有几种方式(必须注意这里说的内核的”一个线程“某种程度上就是前面说的”进程“,因为某些系统其实是不支持”线程“这个概念的):
- 1:1,用户线程和内核线程1:1
对于1:1的模型来说,内核知道的仍然是”一个进程“,在用户空间里创建一个线程就对应着内核中的一个线程。内核中通过线程表记录系统中所有的线程,当某个线程希望创建一个新线程时,它进行一个系统调用,这个系统调用通过对线程表的更新完成线程创建或者撤销。这个线程表中保存了每个线程的寄存器、状态和其他信息。同时,还维护了进程表(同理)。对于线程内部的阻塞系统调用,内核知道这里阻塞了,根据一定的策略,选出同进程中另一个就绪线程执行。
这种方式的缺点是在内核中创建或者撤销线程的代价较大,对于有大量的进程创建或者撤销的情形来说性能影响巨大。不过这种方式的优点是不会因为阻塞系统调用就阻塞掉整个线程。 - N:1, 多个用户线程映射为一个内核线程
这种方式就是把线程包放在用户空间中,内核对线程包”一无所知“,从内核的角度看,它就只看到一个线程。 这种方式最明显的优点是,用户级线程包可以自由选择调度逻辑,用户包可以根据实际需要选择某个线程被执行或者挂起的时机,不用担心线程会在不合适的时机停止。
用户空间管理线程时,每个线程都有专用的”线程表“,包括程序计数器、栈指针、寄存器和状态等,同上面的内核线程一致。当某个线程做了例如等待其他线程完成某项工作之类的会引起本地阻塞的事情后,它调用的这个”运行时“过程会检查是否需要进入阻塞状态,如果是,则会保存当前线程的寄存器,查看进程中可以运行的线程(也就是就绪了的),把新线程的数据载入内存,切换指令指针寄存器,新的线程就执行了。如果硬件提供保存所有寄存器和载入寄存器的指令,这些逻辑可以在很短的时钟周期内完成,性能非常不错。
用户级线程包的一个明显的问题是,如何进行阻塞系统调用。因为内核对进程中的其他”线程“是一无所知的,当某个线程进行阻塞系统调用,比如等待键盘输入、磁盘读取、网络读取时,阻塞会导致整个进程被阻塞,也就是所有线程都被阻塞。这个问题现行的解决办法是把阻塞调用改为非阻塞调用,比如网络调用中常用的Selector
接口,读取文件时read
返回可读取大小等等。
另外一个问题是,某个线程的程序调用产生了一个缺页时(也就是引用的虚拟地址没有在内存中被映射),就会产生一条缺页故障,操作系统会到磁盘上取回这个没有被映射的地址的内容(和相邻的数据)。这一过程中,由于内核不知道用户线程的存在,会阻塞整个”进程“的所有线程。但实际上这个时候其他线程是可以运行的。
还有一个问题就是某个线程在执行的时候,除非这个线程主动让出CPU,不然其他线程没有机会被执行。因为对于内核来说只看到一个线程。一个可能的实现方式是在用户线程包中请求每秒一次的时钟信号(中断)来调度程序,但是这个是生硬和无效的。 - M:N,通常M>N,也就是用户空间中动态的把多个线程映射到有限的内核线程
这种方式其实也就是一种混合实现方式。编程人员决定多少哥用户线程和多少哥内核线程彼此多路复用。某些内核线程会被多个用户线程复用。用户线程包决定映射哪些线程到哪些内核线程。内核级的线程切换需要保存线程的完整栈和载入新线程的完整栈,而用户态的线程不需要这一步,只需要保存必要的寄存器以及更改指令指针寄存器即可。这种方式带来了最大的灵活性,但是,实现的复杂性、如何解决“阻塞系统调用阻塞整个进程“依然是一个问题。
Linux的线程模型
linux
从2.6以后,引入了一个被称为NPTL的模型,这个包是现在linux下主流的模型,几乎都使用的这个模型,而以前的LinuxThread由于过时就不多讨论。NPTL
是最初由Red Hat
开发的一个线程模型,这种模型对应着上述的1:1,也就是一个用户线程被映射会一个内核线程。这是一个完全兼容POSIX标准的实现,它解决了linux以前的线程包的一些问题,带来了更好的性能等等,所以逐渐成为linux上线程的事实实现方式。很多上层编程语言都是通过这个接口来实现自身的线程抽象。NPTL
为什么采用这种1:1的模型,除了实现简单之外,一个很重要的原因是Linux
上的对线程创建的优化: - 常数时间的内核切换
- 内部调用优化,例如
pthread_create, exit
等等
这里有篇文章较为详细的阐述了这一过程。
Java(JVM)的线程抽象
这里说Java的线程抽象,其实某种程度上不完全准确的,因为Java仅仅作为JVM平台上的”一个“语言,而线程肯定是JVM这种中间层的一个通用概念,所以这里实际上说的就是JVM的线程实现。
进一步的说,我们讨论的JVM线程模型通常都指的是服务器上的,而Linux毫无疑问是最为广泛使用的OS。在JVM文档里清楚的写着:
The basic threading model in Hotspot is a 1:1 mapping between Java threads (an instance of java.lang.Thread) and native operating system threads. The native thread is created when the Java thread is started, and is reclaimed once it terminates. The operating system is responsible for scheduling all threads and dispatching to any available CPU
JVM的线程模型是1:1对应着操作系统本地线程的,当Java线程开始的时候本地线程即被创建,并且随着Java线程终止而终止。操作系统负责调度所有的线程和给线程分配CPU。
在JVM内部,通过一个叫做JavaThread
的对象代表着Java中的某个线程(通过OOP,前面内存模型文章里有说过),通过一个叫做OSThread
的对象代表着操作系统的线程。见
OsThread代码
Linux实现
所以,JVM平台上的线程模型与我们经典的Linux线程模型并无区别,他们之间仅仅是说在JVM上运行的语言往往提供了Thread
之上的封装,但是这个没有改变JVM线程模型的本质: 1:1操作系统线程,而Linux的线程又是完全的1:1内核线程。所有线程的调度都是有操作系统来管理的。
由于线程模型本质上的相同,所以在Java里多线程编程与使用C/C++
这种系统语言在Linux
上编写没有多大的区别,仅仅是某些语言写法或者特性上的不同。
Go的线程模型
前面说过,除了1:1这种模型,还存在M:N这种模型。这个模型带来了最大的灵活性和性能优势,但是缺点是实现复杂和上层语言上的模型设计问题。比如说阻塞系统调用、缺页等依然会是一个需要解决的问题。
但是Go语言的goroutines
给这个问题给出了一个目前来说非常优雅的方式。
相较于依赖内核来管理线程的时间片,goroutines
是协作调度的。goroutines
的调度仅仅发生在如下的一些节点:
Channel
的阻塞接收和发送go
声明。但是不保证这个新的goroutines
会立即被执行。- 阻塞系统调用,比如文件或者网络
- GC停顿完成后
上面几个原则解决了前述的用户级线程的阻塞系统调度问题。
多个goroutines
被go runtime
映射到一个操作系统线程里,这使得goroutines
的创建和销毁变得非常轻量。一个进程中成千上万的goroutines
变得可行。某种意义上,goroutines
可以类似为方法调用,每当到达goroutines
的调度点,go runtime
就自动的执行goroutines
切换逻辑,类似于在我们字节码中插入多个“切面”。编译器知道每个goroutines
所使用的寄存器并且自动保存,然后“小心”的把goroutines
调度到某个可用的操作系统线程里。
前说说过,对于线程切换,栈的切换也是一个很重的操作。而对于用户态的线程则完全不是。因为对于内核来说,它看到的可能仅仅是“一个线程”,不需要进行线程的栈的完整的保留,对于内核来说,它看到的就是一个线程的“笔直”的一个执行顺序。而实际上,中间可能多次切换到其他线程去了。由于多个goroutines
在同一个系统线程里,它们有着相同的虚拟地址空间,这里的切换也只需保存必要的寄存器,然后切换PC(下一步执行的代码的寄存器)寄存器即可。当然,每个用户线程依然需要有自己的栈。
Go的栈管理
一个典型的进程的内存结构是这样的:
在进程的地址空间里面,通常堆在地址空间的下部(仅仅在程序数据段上方),向上声场; 而栈通常在顶部,向下生长。操作系统为了避免栈和堆的重叠,通常在中间会加入一个成为guard page
的空间以标识各自的范围:
而对于一个进程里的多个线程,它们共享地址空间,但是又有各自的栈,所以结构通常是这样的:
每个线程有自己的栈空间,也有各自的guard page
。这是通常的实现。但是,这里很明显的问题,对于每一个栈分配的空间的大小的估计是一个很重要的问题,分配的过小程序会出错退出,过大则浪费了很多的地址空间。
前面说道go运行时调度很多个goroutines
到很小的线程里,这是怎么实现的呢?
每一个goroutines
初始都有一个很小的栈空间,随着运行可能会动态改变。在go 1.5的时候是2k。
go并没有使用guard page
,而是在所有的方法调用里都插入了栈空间检查函数,对于还有足够空间运行的则直接运行,否则,就在堆里分配一个较大的块作为栈空间并且拷贝当前栈空间的内容过去并且清0旧的空间然后执行函数调用。当然,对于不用的空间依然会有复用,这个同JVM的回收一个道理。
这种方式,使得goroutines
的创建变得真正的“轻量”,调度也变得很快速了。
当然,这里必须注意的是对于
goroutines
的栈如果过大的时候会导致新分配内存和内存拷贝,会很大程度上影响性能。所以代码应该是要仔细设计的。
最后,goroutine
是新概念么,它在编程语言的历史中*新东西**么,这个后面再说