关于并发的那些事儿

前言

突然发现有一篇去年的文章没有发,这会儿通过一下。不能懒啊。

最近一段时间没怎么写博客,一方面觉得基础性的没意义,不如看书;另一方面就是4、5月份竟然得了支气管炎,天我才40岁呢。
这次的动机,起因于朋友喊着帮忙整理一点题,作为考察架构师在多用户、大并发的水平。说实话,个人觉得简单的题毫无意义。稍微有点经验的,javaer,谁不知道synchronized、lock、volatile;gofaner呢,似乎也就chan、Mutex、WaitGroup了。但如果要求描述对应语言的底层原理,估计不到5%。再要求到微机原理,1%通过率都不到。但这有意义吗?
我们要求的架构师,更应该是在理解这几个关键词的限制要求基础上,确定一个能够简化业务代码的架构。日常开发中,代码频繁可见lock、unlock,估计bug不少。那么如何在代码中尽量没有锁关键字,而又具备大并发能力呢?

起步-玩家在线查询的数据定义

我们假定一个玩家在线查询服务,玩家就一个ID和在线状态,那么,可以这样定义两个最基本的结构:

type PlayerId int
//玩家在线状态
type Player struct {
	Id    PlayerId
	Login bool
}
//在线控制器
type OnlineManager struct {
	cache map[PlayerId]Player
}
func (om *OnlineManager) Login(id PlayerId) {
	//...
}

func (om *OnlineManager) Logout(id PlayerId) {
	//...
}

假定内存足够大,所有玩家的在线状态足以放入。控制器也只提供Login和Logout来更改对应PlayerId的Login属性。如果这个控制器放在一个多核的系统下,如何充分利用其处理性能呢?

第一步:最常见的伪并发代码

先了解并发(concurrency)和并行(parallellism)的区别。通常,并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔发生。并行是在不同实体上的多个事件,并发是在同一实体上的多个事件。
在实际的单机程序,要能够利用多核计算能力,又能够避免单一实体的读写冲突,才算是一个基本合格的并发程序。对于前者,我们通常是抛一个协程了事。而读写冲突,则是锁来规避:

type OnlineManager struct {
	mut   sync.Mutex   //<-- 增加了一把锁
	cache map[PlayerId]Player
}

func (om *OnlineManager) Login(id PlayerId) {
	om.withLocker(func() { om.login(id) })
}
//采用回调函数方式,封装竞争资源的读写锁使用,简化外部代码
func (om *OnlineManager) withLocker(fn func()) {
	om.mut.Lock()
	defer om.mut.Unlock()
	fn()
}

func (om *OnlineManager) login(id PlayerId) {
	var player = om.cache[id]
	player.Login = true
	om.cache[id] = player
}
//忽略基本雷同的Logout代码

代码中,Login其实就是一个壳,数据读写被withLock组装,避免在具体业务中直接使用锁。以避免重构时很可能出现重复加锁带来的死锁,或忘记加锁带来的读写冲突。而且可以想象或测试发现,当多个核 cache 访问同一块内存时,必须强迫 CPU 串行化,实际效率并不高。因此,不是真正的并发代码。

当然也可以参考32位时代,内存对齐,用c语言,底层函数可以直接操作单链、数组等,但这并不是趋势。不仅开发效率低、容易出错、不便于重构,并且数据结构也有很大的限制。

第二步:分区/分组

既然前面存在强迫串行化问题,那么我们首先考虑无临界区的内存访问就好。将这个map分拆为多个,按照PlayerId对某个整数(2的n次方)求模就好,然后提供对应数量的锁,这样,基本上可以解决大部分强制串行化问题。
如果重构这部分代码,我们通常可以这么来组织:

type PlayerKV map[concorrency.PlayerId]concorrency.Player

type OnlineManager struct {
	size int
	mut   []sync.Mutex
	cache []PlayerKV
}

func NewOnlineManager(parrSize int)*OnlineManager{
	//忽略
}

func (om *OnlineManager) Login(id concorrency.PlayerId) {
	om.withLocker(id,func() { om.login(id) })
}

func (om *OnlineManager) withLocker(id concorrency.PlayerId,fn func()) {
	var offset=int(id)%om.size        //<-- 通过id计算偏移量
	om.mut[offset].Lock()
	defer om.mut[offset].Unlock()
	fn()
}

func (om *OnlineManager) login(id concorrency.PlayerId) {
	var offset=int(id)%om.size         //<-- 通过id计算偏移量
	var player = om.cache[offset][id]
	player.Login = true
	om.cache[offset][id] = player
}
//忽略Logout类似代码

可以写一个测试,会发现对cpu的利用率明显提高。但依然存在两个问题:

  1. 业务方法中,调度分配需要的offset计算出现在多个地方。
    我们可以将其统一为一个通用的内部函数(或者用一个内部属性指向闭包返回函数也行)。也可以将锁放到PlayerKV中,OnlineManager只用来处理调度。具体代码就不再重复,比较简单。
  2. 当offset计算得到的值相同时,则login依然会强制串行进而阻塞
    这一点也容易理解,当offset相同,则访问同一个PlayerKV实例,必然涉及到访问冲突等待的情况。

第三步:通过队列规避阻塞

通过队列——chan、mpsc——也是一种无锁的方式。当性能要求依然高,为了简化后端业务的逻辑,这是很好的选择。通常模块分为两部分:

  • 队列控制逻辑,确保入队列、出队列的顺序性。这对于秒杀场景是非常有用的。
  • 出队列后的业务逻辑,这时候通常是串行,可以无锁。
    现实中,如果单机不考虑网络数据的接收,chan或者自己手写一个mpsc都比较简单,仅仅只是一个简单的小模块。但涉及到服务器之间,就需要考虑tcp/udp、序列化/反序列化。这部分,有很多参考的文章,如果了解系统可以看看akka和protoactor。前者是scala写的,但文档非常系统,适合顶层学习。
    需要强调,基于队列的业务逻辑和平时没太多不同,没有锁的并发逻辑,才是好的代码。无论是代码模块还是产品,越简单、越能解决问题,才能复用。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值