进程与线程
概念
在面向进程设计的系统中,进程(process)是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。 进程是程序(指令和数据)的真正运行实例。用户下达运行程序的命令后,就会产生进程。同一程序可产生多个进程(一对多关系),以允许同时有多位用户运行同一程序,却不会相冲突。
线程(thread)是操作系统能够进行运算调度的最小单位,线程分为内核线程、轻量级进程、用户线程;内核线程是指操作系统内核调度的线程,如Win32线程;用户线程是由用户进程自行调度的线程,如Linux平台的POSIX Thread; 轻量级进程(LWP)是建立在内核之上并由内核支持的用户线程。
【内核线程】<->【轻量级进程】<->【用户线程】
在用户空间模拟操作系统对进程的调度,来调用一个进程中的线程,每个进程中都会有一个运行时系统,用来调度线程。此时当该进程获取cpu时,进程内再调度出一个线程去执行,同一时刻只有一个线程执行。
内核级线程:切换由内核控制,当线程进行切换的时候,由用户态转化为内核态。切换完毕要从内核态返回用户态。
系统调度
线程本质可以看成是一系列的指令集。
线程一般分为三种状态:
- 阻塞态:表示线程已经停止,需要等待一些事情发生后才可继续。这有很多种原因,比如需要等待硬件(磁盘或网络),系统调用,或者互斥锁(atomic, mutexes)。这类情况导致的延迟,往往是性能不佳的根本原因。
- 就绪态:这代表线程想要一个 CPU 核来执行被分配的机器指令。如果你有很多个线程需要 CPU,那么线程就不得不等待更长时间。此时,因为许多的线程都在争用 CPU,每个线程得到的运行时间也就缩短了。
- 运行态:这表示线程已经被分配了一个 CPU 核,正在执行它的指令。与应用相关的工作正在被完成。这是每个人都想要的状态。
CPU密集型任务和IO密集型任务 CPU密集处理任务中线程很少进入阻塞态。它一直都需要使用 CPU,因此线程的切换并没有用,甚至会产生负面效果,主要通过多核并行来解决问题。这种工作通常都是数学计算。比如计算圆周率的第 n 位的工作就属于 CPU密集型的工作。 IO密集任务线程会经常进入阻塞态,比如网络请求资源,或者系统调用。一个需要访问数据库的线程属于 IO密集的。互斥锁的使用也属于这种。这时候线程的切换来提升并发量。
线程调度存在的问题
昂贵的代价
- 上下文切换
- Cache Line 命中率
上下文切换
上下文切换是指调度器把一个线程从CPU核上拿下来,把另一个就绪态的线程放到CPU核上。线程的上下文切换时间一般是50~100ns,这是为什么如果对于计算密集型的任务频繁切换反而会导致效果更差。
Cache Line 命中率
由于访问主内存很耗时间,CPU大部分会访问cache,现代CPU缓存一般分为了三层。离CPU越远的,其访问速度越慢。因此提高 Cache Line 命中率是提高性能的很重要的而一种方法,一般来说优化缓存可从三个方面入手:一、减少命中时间;二、降低失效率;三、减轻失效代价。 但是,对于多核系统来说,多线程在每个核都有一份它自己所需要数据的拷贝,随着 CPU 核上运行的线程的改变,不同的线程需要访问的数据不同,从而导致同一个 cache line 中的数据被修改了,其他所有核上的 cache line 拷贝都标记为“不可用”,当其他核上的线程试图访问或修改这个数据时,需要重新从主内存上拷贝最新的数据到自己的 cache 中。
Goroutine 的模型设计
P:是一个逻辑处理器的概念,当P有任务时需要创建或者唤醒一个系统线程来执行它队列里的任务,所以P/M绑定构成一个执行单元。 当你的 Go 程序启动之初,它会被分配一个逻辑处理器,每一个 P 会被分配一个系统线程(M)。这个 M 会被操作系统调度,操作系统把线程(M)放到一个 CPU Core 上去执行,在执行的时候,每个线程都被绑定上了一个独立的 P 。
M:是一个线程或称为Machine,所有M是有线程栈的。
G:表示一个Goroutine。
执行队列 在 Go 调度器中有 2 个不同的执行队列:全局队列(Global Run Queue, 简称 GRQ)和本地队列(Local Run Queue,简称 LRQ)。每一个 P 都会有一个 LRQ 来管理分配给 P 上的 Goroutine。这些 Goroutine 轮流被交付给 M 执行。GRQ 是用来保存还没有被分配到 P 的 Goroutine。 LRQ是 Lock-Free 的,处理速度快;GRQ为保证数据竞争问题,需要加锁处理,速度比LRQ慢,因此 P 处理完LRQ的时候会先找其他 P 的LRQ,最后再去找GRQ。
goroutine队列调度
runtime.schedule() {
// only 1/61 of the time, check the global runnable queue for a G.
// if not found, check the local queue.
// if not found,
// try to steal from other Ps.
// if not, check the global runnable queue.
// if not found, poll network.
}
Goroutine调度为何更好?
- Go的调度是基于用户态事件而非抢占式的,比如关键字
go
、垃圾回收、同步互斥操作如Lock()
Unlock()
。 - Go通过尽可能多在M上来运行goroutine,从而提高CPU的利用率,Go通过"spinning threads"最小化系统线程的切换。
- 利用gorountine可以避免系统线程的切换,从而提高Cache Line 命中率。
- 线程的stack size更大(≥ 1MB),而gorountine得stack size只有2KB,启动更慢。