字节一面:go的协程相比线程,轻量在哪?


  1. 传统cpu调度背景 #操作系统原理#
  2. 线程切换的时机和代价
  3. go是怎样的思路:
  • 将调度维持在用户态
  • 推出用户态runtime代码实现的轻量级线程
  1. go 调度策略
  • 常规: 本地队列- 其他队列、全局队列
  • 协作式调度: 基于用户态事件
    -- 异步io: 网络: 基于netpoller
    -- 同步io: 产生M
  1. goroutine的生命周期的实现

只想关注golang goroutine背景、调度方式、生命周期的请关注3,4,5部分。

1.传统cpu调度背景: 应用不能既当运动员,又当裁判员

操作系统分为用户态和内核态(或者叫用户空间和内核空间), 那内核态究竟是什么呢?

计算机是多进程操作系统,多个用户进程同时都在利用显性的物理资源:cpu、内存、io, 不能让用户进程既当运动员,又当裁判员。

于是操作系统抽象出了内核对象,用于实现进程同步、进程通信、内存管理等系统资源的分配和管理。操作系统维护着内核对象表,每个内核对象都在这张表上记录了状态和属性。

内核对象由内核态代码创建,用户可以系统调用、api函数来操作和管理内核对象,与此同时用户进程坠入内核态。

eg: C# Thread类表示一个线程(是托管代码),而线程是内核对象,故C#使用Thread创建线程,实际是由操作系统创建了一个内核对象来实现线程。

画外音:
os作为裁判员,内核态是一种特殊的调度程序,统筹计算机的硬件资源,例如协调CPU资源、分配内存资源、并且提供稳定的环境供应用程序运行`。

2. 线程切换的时机和代价?

  • 线程是cpu调度的基本单位,进程是资源占有的基本单位。
  • 线程中的代码是在用户态运行,而线程的调度是在内核态
① 自发性上下文切换 线程受用户代码指令导致的切出
Thread.sleep() 线程主动休眠
object.wait() 线程等待锁
Thread.yield() 当前线程主动让出CPU,如果有其他就绪线程就执行其他线程,如果没有则继续当前线程
oThread.join() 阻塞发起调用的线程,直到oThread执行完毕

② 非自发性上下文切换: 来自内核线程调度器管控
线程的时间片用完 ,cpu时间片雨露均沾
高优先级线程抢占
虚拟机的垃圾回收动作

线程上下文切换的代价是高昂的:上下文切换的延迟取决于不同的因素,大概是50到100ns左右,考虑到硬件平均在每个核心上每ns执行12条指令,那么一次上下文切换可能会花费600到1200条指令的延迟时间

① 直接开销
保存/恢复上下文所需的开销
线程调度器调度线程的开销
② 间接开销
重新加载高速缓存
上下文切换可能导致 一级缓存被冲刷,写入下一级缓存或内存

3. go的协程轻量级体现在哪?

如上面所述,常规线程切换会导致用户态程序和内核态调度程序的切换

大佬们思考了另外一个思路:将调度维持在用户态

go推出了用户态runtime实现的轻量级线程goroutine, go将goroutine的调度维持在用户态, 这是由GPM中的P Process来完成的, 功能类比于常规的操作系统线程调度器

(1) 上下文切换代价小: P 是G、M之间的桥梁,调度器对于goroutine的调度,很明显也会有切换,这个切换是很轻量的:
只涉及

  • PC (程序计数器,标记当前执行的代码的位置)
  • SP (当前执行的函数堆栈栈顶指针)
  • BP 三个寄存器的值的修改;

而对比线程的上下文切换则需要陷入内核模式、以及16个寄存器的刷新。

(2) 内存占用小: 线程栈空间通常是2M, Goroutine栈空间最小是2k, golang可以轻松支持1w+的goroutine运行,而线程数量到达1k(此时基本就达到单机瓶颈了), 内存占用就到2G。


4. GO 协程调度时机

通常情况下:

go关键字产生的一个常规执行逻辑的goroutine,由P调度进队列,等到被M执行完之后,调度器P继续从本地队列调出G给到M执行,若没有则从其他队列/全局队列偷取G。


存在P的本地队列、全局队列、parked goroutines(阻塞的协程)

Go scheduler is not a preemptive scheduler but a cooperating scheduler. Being a cooperating scheduler means the scheduler needs well-defined user space events that happen at safe points in the code to make scheduling decisions. The followings are the opportunities for scheduling:

GO调度器是协作式,非抢占式,这意味着调度器是基于用户空间的事件来做出 调度决策。下面是调度的时机。

① The use of the keyword go

This is how we create a new goroutine, scheduler gain an opportunity when a new goroutine was created.

② Synchronization and Orchestration

If an mutex, or channel operation call will cause the Goroutine to block, the scheduler can context-switch a new Goroutine to run. Once the Goroutine can run again, it will be re-queued automatically.

③ System calls

Including async and sync system calls, go has different way to deal with them. With async type like network request, a network poller would be used, goroutine that might block is moved to net poller, let the proccesor can execute the next one.
With sync type like file I/O, the current pair of G and M will be seperated from G, P, M model. Meawhile, a new machine would be created in order to keep the original G, P, M model working, and the block goroutine would be take back while system call finished.

④ Garbage collection

Since the GC runs using its own set of Goroutines, those Goroutines need time on an M to run, scheduler needs a opportunitt to handle that

系统调用(system call)又分为两种,同步和异步系统调用。
同步和异步系统调用是指在系统调用过程中,用户程序和操作系统之间的交互方式。
同步系统调用(Synchronous System Call)是指用户程序在进行系统调用时,必须等待系统完成操作并返回结果后才能继续执行。在进行同步系统调用时,用户程序会阻塞,直到系统调用完成。同步系统调用的优点是操作简单,易于实现,但缺点是会造成用户程序的阻塞,影响程序的响应性能。
异步系统调用(Asynchronous System Call)是指用户程序在进行系统调用时,可以在系统调用的同时继续执行其他操作,无需等待系统调用的完成。在进行异步系统调用时,用户程序不会阻塞,而是会通过回调函数等机制在系统调用完成后再进行处理。异步系统调用的优点是可以提高程序的并发性和响应性能,但缺点是实现较为复杂。
在实际的系统编程中,通常需要根据具体的需求和场景选择使用同步或异步系统调用。例如,在需要进行文件IO等较为简单的操作时,可以使用同步系统调用;在需要进行网络通信等较为复杂的操作时,可以使用异步系统调用,以提高程序的并发性和响应性能。


5. goroutine生命周期

Go 必须对每个运行着的线程上的 Goroutine 进行调度和管理。
这个调度的功能被委托给了一个叫做 g0 的特殊的 goroutine, g0 是每个 OS 线程创建的第一个goroutine。

g0为新创建的goroutine

  • 设置PC/SP寄存器
  • 更新goroutine内部的 ID和status


Go 需要一种方法来了解 goroutine的结束。

这个控制是在 goroutine 的创建过程中,在创建 goroutine 时,Go在开启实际go执行片段之前,通过PC寄存器设置了SP寄存器的首个函数栈帧(名为goexit的函数),这个技巧强制goroutine在结束工作后调用函数goexit

newg.sched.pc = funcPC(goexit) + sys.PCQuantum
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)

//------------- ......

// adjust Gobuf as if it executed a call to fn with context ctxt
// and then did an immediate gosave.
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
	sp := buf.sp
	...
	sp -= sys.PtrSize
	*(*uintptr)(unsafe.Pointer(sp)) = buf.pc
	buf.sp = sp
	buf.pc = uintptr(fn)
	buf.ctxt = ctxt
}

https://www.sobyte.net/post/2022-02/where-is-goexit-from/

5. goroutine的常规实践


  • 在一个函数前放置go即可开启一个go的 协程,如其他函数一样,可以有形参,不过函数返回值会被忽略。

  • 在golang中, 大家习惯使用一个封装了业务逻辑的闭包来启动一个goroutine, 该闭包负责管理并发的数据和状态,例如闭包从信道中读取数据并传递给业务逻辑, 业务逻辑完全不知道它是在一个goroutine中,
    然后函数的结果被写回另外一个信道,这种职责分离使代码模块化、可测试,并使得api调用简单,无需关注并发问题。

func process(val int) int {

}

func runningConcurrently(in <-chan int, out  chan <- int) {
   go func() {                        // 业务逻辑协程
       for val := range in {
           result := process(val)
           out<- result               // 利用信道来在协程间通信
       }
   }
}

ref


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2026 CODEPRJ.COM