这篇文章我们来了解一下隐藏在Go运行时中的一些并发原语, 因为运行时是底座和包循环依赖等原因,运行时中很少使用标准库中的并发原语,它有自己的并发原语。
mutex
在runtime/runtime2.go 定义了一个互斥锁,它的定义如下:
|
|
它可是运行时中的大红人了,在很多数据结构中都被广泛的使用,凡事涉及到并发访问的地方都会用到它,你在runtime2.go
文件中就能看到多处使用它的地方,因为很多地方都在使用它,我就不一一列举了在runtime这个文件夹中搜mutex
这个关键子就都搜出来了。
举一个大家常用来底层分析的数据结构channel
为例,channel的数据结构定义如下:
|
|
最后哪个字段lock mutex
就是使用的这个互斥锁。因为一个通道在发送和接收的时候都会涉及到对通道的修改,在多发送者或者接收者情况下,需要使用互斥锁来保护。
这个互斥锁的使用需要调用几个函数。
- lockInit: 需要初始化这个锁,比如在channel的实现中,有如下的初始化代码:
lockInit(&c.lock, lockRankHchan)
, 它将lock初始化(lockInit)时设置锁的等级(rank)。如果不明确去初始化一个锁,那么可以在调用lock自身的时候通过lockWithRank指定这个锁的等级。这个等级在启用GOEXPERIMENT=staticlockranking
用来加强锁的静态分析。 - lock: 加锁,在不同的操作系统下有不同的实现。如channel使用这个代码进行加锁:
lock(&c.lock)
- unlock: 解锁,在不同的操作系统下有不同的实现。如channel使用这个代码进行解锁:
unlock(&c.lock)
我在Go运行时中的 Mutex中详细介绍了它,这里就不再赘述了。
rwmutex
运行时中还实现了读写锁rwmutex。
这个读写锁完全是从sync.RWMutex
中拷贝过来的,只是将sync.RWMutex
中的sync
包替换成了runtime
包,因为sync
包依赖了runtime
包,所以不能直接使用。
你看它的数据结构定义和sync.RWMutex
几乎是一样的:
|
|
mutex
和rwmutex
会直接阻塞M
。
gopark/goready
在其它编程语言中,会直接提供park
和unpark
的功能,比如rust,提供对并发单元的更底层的控制。
park
就是停止一会,很形象,就是暂时让并发单元阻塞,不再参与调度,直到unpark
它,它才会重新参与调度。
Go运行时并没有直接提供park
和unpark
的功能,它提供了gopark
和goready
的功能,它们的实现在runtime/proc.go。
gopark
会将goroutine放到等待队列中,从调度器的运行队列中移出去,等待被唤醒。goready
会将goroutine放到可运行队列中,加入到调度器的运行队列,等待被调度。
note
note
实现一次性的通知机制。
note
的数据结构如下:
|
|
可以使用notesleep
和notewakeup
进行休眠和唤醒。
就像mutex
一样,notesleep
会阻塞M
,notewakeup
会唤醒一个M
,并且不会重新调度G
和P
,而notetsleepg
就像一个阻塞的系统调用一样,允许P
选择另外一个G
运行。noteclear
用来重置note
总结一下, 上面几种同步原语阻塞的角色如下:
阻塞角色 | |||
---|---|---|---|
同步原语 | G | M | P |
mutex/rwmutex | Y | Y | Y |
note | Y | Y | Y/N |
park | Y | N | N |
filelock
"filelock"(文件锁)通常是指在计算机系统中使用的一种机制,用于确保对文件的独占性访问,以防止多个进程或线程同时修改文件而导致数据不一致或损坏。
一些应用程序经常利用文件锁,来控制只有一个实例在运行,在linux环境下非常常见,比如mysql等。
在不同的操作系统和编程语言中,文件锁的实现方式可能会有所不同。一般而言,文件锁可以分为两种主要类型:
- 共享锁(Shared Lock): 多个进程或线程可以同时获取共享锁,允许它们同时读取文件,但阻止其他进程或线程获取独占锁进行写操作。
- 独占锁(Exclusive Lock): 只允许一个进程或线程获取独占锁,阻止其他进程或线程同时进行读或写操作。
文件锁的代码在cmd/go/internal/lockedfile中,我们以Linux为例,看看它的实现:
|
|
可以看到它实际是调用系统调用syscall.Flock
实现的。
这不属于运行时内定义的同步原语,但是它给我们提供了一个实现文件锁的思路,它甚至还封装了一个Mutex
供我们使用。如果有类似的需求,我们可以参考它的实现。
sema
不太清楚Go为啥不在运行时或者标准库sync中实现信号量,而是在扩展包中去实现,信号量可以说是一个非常广泛使用的同步原语了。
虽然没有在运行时中没有明确实现,但是运行时中的runtime/sema.go提供了与信号量相近功能,而且sync.Mutex
严重依赖它。
这个实现旨在提供一个可以在其他同步原语争用的情况下使用的睡眠和唤醒原语,因此,它的目标与Linux的futex相同,但语义要简单得多。
Go团队说你不要将这些视为信号量,而是将它们视为一种实现睡眠和唤醒的方式,以确保每个睡眠都与单个唤醒配对,
这是有历史原因,这些从贝尔实验室出来的大佬,对于先前他们在Plan 9中的一些想法一脉相承的继承下来,这个设计可以参见 Mullender 和 Cox 的Plan 9中的信号量。
比如sync.Mutex
睡眠和唤醒的函数其实就是这里实现的:
|
|
atomic
atomic 提供原子操作,独立于sync/atomic
,仅供运行时使用。
在大多数平台上,编译器能够识别此包中定义的函数,并用平台特定的内部函数替换它们。在其他平台上,提供了通用的实现。
除非另有说明,在此包中定义的操作在处理它们所操作的值时对线程是有序一致的(sequentially consistent)。更具体地说,在一个线程上按特定顺序发生的操作,将始终被另一个线程观察到以完全相同的顺序发生。
因为和特定的CPU架构有关,它的实现针对不同的CPU架构,由不同的指令实现而成,而且基本使用汇编实现,比如AMD64下的Cas实现,使用了LOCK
+ CMPXCHGL
指令:
|
|
其实sync/atomic
下的实现,也是调用这里的实现,否则维护两套代码就太麻烦了,而且可能出现不一致的现象。你看sync/atomic/asm.s
:
|
|
它也是调用untime∕internal∕atomic
下对应的函数。
singleflight
singleflight
特别适合大并发情况下许多请求做同一件事情的场景,这个时候只处理一个请求就可以了,其它请求等待那一个请求的结果,这样对下游的压力大大减少,比如在读取cache的时候。
因为它在特定场景下很有用,Go的扩展库中也同样实现了它。
它没有定义在运行时中,而是定义在internal/singleflight中。
比如在包net
中,我们查找一台主机的IP地址时,如果并发的请求,对资源是很大的浪费,这个时候我们只让一个请求处理就好了:
|
|