并发错误臭名昭著,是令人十分崩溃的 bug。大多数直播平台开发的 bug 是一致的。如果你先做 X,然后做 Y,然后做 Z,你将会得到 Bug A。
但通过并发,你会在直播平台开发中遇到竞争条件(race condition)。这是一个 bug,如果你做 X,然后做 Y,你可能有 10% 的几率得到 Bug A。错误的出现是间歇性的,这使得你很难找到错误根本原因,因为你不能可靠地重现它。这也使得你很难证明你确实解决了这个问题。如果 Bug A 发生的几率只有 10%,那么你就需要多次尝试重现该 Bug,以确信自己已经修复了它。
在直播平台开发时处理并发性问题是我职业生涯早期的谋生之道。我喜欢使用多线程并修复高级开发人员错过的竞争条件,这种工作可以大幅提升我自己的信心。
就在那时,我意识到我擅长某种类型的并发问题,而这类问题恰好是大多数并发问题的原因。
简单并发问题实践
首先,让我们稍微讨论一下什么是并发。然后,我们将继续讨论一个简单的并发问题,然后是一个更复杂的问题。
并发基本上是让直播平台开发的多个独立的代码段同时运行。让我们从假设开始,然后进入一个真实情况。
假设我需要对一个 API 发出 5 个不同的请求。每一个请求都需要 100 毫秒才能完成。如果我等待一个完成后才开始下一个,我将会等待 500ms。
如果我同时执行这 5 个 web 请求,我最终将等待 100 毫秒加上很少的一些额外开销。
这是一个相当大的性能差异,也是人们通常使用并发的原因所在。
这听起来像是直播平台开发中的一个简单的概念,对吧?这是因为它就是一个简单的概念。
问题在于执行过程。那些 API 请求每个耗时大约 100 毫秒,而不是精确的 100 毫秒。
这意味着你将按顺序发出 API 请求,但返回将是乱序的:
每次运行执行 API 请求的代码时,返回顺序都会不同。
你通过并发性获得了直播平台开发性能改进,但是放弃了一致性。
如果处理这些 API 请求响应的代码使用共享数据,就会出现 bug。
让我们看一个更详细的例子,看看这是如何发生的。Dynomantle 的搜索栏建议有个 bug。
它的问题是:每当你输入一个字符,就会发出一个 api 请求。这是为了让你在键入时能够顺畅地看到提示。你输入“i”,以“i”开头的笔记 / 书签 / 邮件就会弹出来。你输入“in”,列表就会顺滑的变为以“in”开头的内容。
当你知道你要搜索什么时,输入 5 个字符要花多长时间?2 秒?1 秒?半秒?
我仍然需要优化直播平台开发中的这个服务,但是现在处理每个 API 请求需要半秒到一秒的时间。
让用户在键入每个字符后等待一秒钟再键入下一个字符是一种糟糕的用户体验。所以我在用户键入每个字符时发出一个 API 请求。问题是请求返回的顺序不一致。带有 2 个字符的请求可能在带有 5 个字符的请求之后返回。
搜索建议被存储为一个列表。每当响应传入时,整个列表都会刷新。在这种情况下,当最后一个请求返回时,会用正确的建议刷新整个列表,但是当旧的请求返回时,会在列表中填充不正确的建议。
幸运的是,这是一个非常容易解决的问题,因为直播平台开发的请求是按顺序发出的。
- 每次发出请求时生成时间戳或哈希值,这被用作请求 ID。
let requestId = Date.now()
- 将请求 id 设置为带有建议列表的附加变量。因为我们按顺序提交请求,所以这永远是最后一个请求。
let requestId = Date.now()
// Datastore is some singleton for
// easy access to these types of variables
datastore.setLastRequestId(requestId)
- 在每个 API 调用的 success 函数中传递请求 id。
let requestId = Date.now()
datastore.setLastRequestId(requestId)
$.ajax({
success: function(json) {
suggestionsReceived(json, requestId)
},
})
- 当响应到来时,验证它是否是预期请求的响应。
suggestionsReceived(
suggestions: Array,
requestId: number,
) {
if(datastore.lastRequestId != requestId) {
return
}
// the rest of the code
}
不幸的是,如果直播平台开发中用户输入得非常快,他们可能会看到建议列表更新有延迟。即使用户不使用 2 个字符的建议,看到建议列表出现可以提供一种感觉,即应用正在做一些事情,而非只是等待。
解决这个问题需要对上面的代码做一点小小的修改。
我们将继续使用时间戳而不是哈希值。
接下来,我们将存储最后接收到的请求 id,而不是最后发出的请求 id。
let requestId = Date.now()
$.ajax({
success: function(json) {
suggestionsReceived(json, requestId)
},
})
suggestionsReceived(
suggestions: Array,
requestId: number,
) {
datastore.setLastRequestId(requestId)
// the rest of the code
}
最后,只有当直播平台开发响应的请求 id 高于最后接收到的请求 id 时,我们才会刷新列表。因为我们使用时间戳作为请求 id,所以所有请求都是有序的,id 越大请求就越新。
suggestionsReceived(
suggestions: Array,
requestId: number,
) {
if(datastore.lastRequestId > requestId) {
return
}
datastore.setLastRequestId(requestId)
// the rest of the code
}
注意:这一机制运作的前提是满足以下条件:用户不会在同一毫秒内键入多个字符。如果他们这样做,他们是在粘贴内容,此时我们只需要进行一次 api 请求。
另外需要注意的是,这也只适用于 Javascript 处理并发性的方式。它并不是真正的并发。每个函数都在另一个函数运行之前执行并完成。
在 Java 中尝试类似的代码,你会感觉很糟糕。因为对 suggesReceived() 的多个调用可能同时执行。这意味着对“in”和“inv”的建议响应都可以通过 if 语句中的检查,然后执行函数的其余部分。
suggestionsReceived(
suggestions: Array,
requestId: number,
) {
if(datastore.lastRequestId > requestId) {
return
}
// 2 function calls can end up here at the same exact time.
datastore.setLastRequestId(requestId)
// the rest of the code
// Maybe the results for "inv" get set slightly faster,
// then the results for "in" get set.
// We end up with old suggestion results again.
}
你看到的直播平台开发行为将非常不一致,这取决于函数其余部分的长度和两个函数调用的时间。要使它在真正的并发编程语言中正常运作,你需要查找如何在该语言中使用锁。如果你要处理跨多个服务器的并发,也可以考虑 Redis 的分布式锁。
当某一个函数拥有锁时,锁可以阻止其他函数执行。如果我们需要在 Javascript 中使用锁,它应该是这样的:
suggestionsReceived(
suggestions: Array,
requestId: number,
) {
// Wait for the lock to be unlocked before continuing
lock.lock()
if(datastore.lastRequestId > requestId) {
return
}
datastore.setLastRequestId(requestId)
// the rest of the code
// Let other functions waiting for the lock execute.
lock.unlock()
}
当然,这样做有风险,如果我们从不解锁,那么其他函数就不会执行。如果我们在多个函数中使用多个锁,可能会出现两个函数都在等待的情况,此时它们都在等待对方已经锁定的锁。我们的程序现在卡住了,因为两个函数都不能执行。这就是所谓的死锁情况。
Dynomantle 中的搜索建议 bug 是一个简单的并发问题,因为它是在 Javascript 中。让我们探讨一个更复杂的问题,它发生在 Java 中,但它的教训应该对许多其他问题有帮助。