我曾经开发过一个应用程序,该应用程序使用Timer
定期 ping 带有状态更新的服务器。 在应用程序的一个构建中,我们注意到状态服务器开始遇到 CPU
峰值,最终导致它无法处理更多请求。
经过调查,我们发现对应用程序逻辑的一些简单更改导致我们不小心使服务器的请求过多。 计时器 A
将被设置为根据特定条件发送状态更新。 计时器将启动,更新将被发送。 引入的错误是计时器没有正确失效并且会保持运行。 当满足另一个条件时,我们将创建一个新的计时器 (B)
来发送状态更新。 除了现在我们同时运行 A
和 B
,所以他们都会尝试发送请求。 然后,当请求开始超时时,完成处理程序将设置另一个计时器重试,这对两个计时器都会发生,导致 C
和 D
。一个计时器变成两个,变成四个,变成八个,然后是 16,然后 32…我们的服务器以指数级增长的请求数量猛增。
立即修复是更新服务器以立即拒绝所有传入请求。 然后我们为应用程序发布了一个紧急错误修复程序,以修复计时器失效行为。
但是……首先让应用程序阻止这种情况发生不是很好吗? 我们可以做到这一点,而且会非常简单。
受限请求
让我们看看我们的 HTTPLoader
接口来提醒我们自己的 API
契约:
open class HTTPLoader {
/// Load an HTTPTask that will eventually call its completion handler
func load(task: HTTPTask)
/// Reset the loader to its initial configuration
func reset(with group: DispatchGroup)
}
请记住,load(task:)
方法不承诺何时执行任务。 加载程序可能会收到一个 HTTPTask 并立即开始执行它,或者它可能会等待几秒钟、几分钟、几小时或几年。 对于 API
的客户端,没有关于执行时间的承诺。
节流加载程序可以利用这一点。 当它收到 HTTPTask
时,它可以查看是否允许继续加载它。 如果是,它可以愉快地将任务传递给链中的下一个加载器。 如果不允许加载它,它可以将它放在任务列表中以便稍后执行。
我们的整体界面看起来像这样:
public class Throttle: HTTPLoader {
public var maximumNumberOfRequests = UInt.max
private var executingRequests = [UUID: HTTPTask]()
private var pendingRequests = [HTTPTask]()
public override func load(task: HTTPTask) {
if UInt(executingRequests.count) < maximumNumberOfRequests {
startTask(task)
} else {
pendingRequests.append(task)
}
}
private func startTask(_ task: HTTPTask) {
let id = task.id
executingRequests[id] = task
task.addCompletionHandler {
self.executingRequests[id] = nil
self.startNextTasksIfAble()
}
super.load(task: task)
}
private func startNextTasksIfAble() {
while UInt(executingRequests.count) < maximumNumberOfRequests && pendingRequests.count > 0 {
// we have capacity for another request, and more requests to start
let next = pendingRequests.removeFirst()
startTask(next)
}
}
}
这个(不完整的)实现为我们提供了节流如何工作的基本概念。 当我们收到请求时,我们会检查当前有多少任务在执行。 如果它小于我们允许的最大值,那么我们可以开始执行这个请求。 如果我们达到(或超出)我们的限制,我们会将任务放入一个“待定”请求数组中,以表明它需要等待加载。
加载任务会将其添加到当前正在执行的任务列表中,很像我们上次创建的 Autocancel 加载程序。 当它完成时,它会从列表中删除。 此外,当请求完成时,加载程序会检查是否有任务等待加载,以及是否允许执行它们。 如果是,则它将它们从数组中拉出并开始执行它们。
这个简单的实现有几个缺点:
-
它不是线程安全的。 请求可以从任何线程加载,我们正在修改加载器内部的很多状态,但没有确保我们对它有独占访问权。 我们需要一种同步类型(例如 NSLock 或 DispatchQueue)来确保我们正确地更新状态。
-
我们无法对被取消的任务做出反应。 如果一个任务在它仍然挂起时被取消,那么我们可能应该把它从数组中拉出来并调用它的完成处理程序。 幸运的是,添加这个非常简单:
let id = task.id
task.addCancelHandler {
if let index = self.pendingRequests.firstIndex(where: { $0.id === id }) {
let thisTask = self.pendingRequests.remove(at: index)
let error = HTTPError(.cancelled, ...)
thisTask.complete(.failure(error))
}
}
pendingRequests.append(task)
- 我们缺少我们的 reset() 逻辑。 从概念上讲,这将类似于我们上次的 Autocancel 加载器:当我们重置时,我们将每个待处理任务和正在执行的任务加入 DispatchGroup。 当每个人完成后,他们分别离开小组。 我们可以在每个任务上调用 cancel(),但理想情况下,我们在链中有一个 Autocancel 加载器,它已经为我们做了这件事。
不受限制的请求
虽然我们有能力限制请求是件好事,但可能存在我们永远不想限制的请求。 这是添加另一个请求选项的好机会:
public enum ThrottleOption: HTTPRequestOption {
public static var defaultOptionValue: ThrottleOption { .always }
case always
case never
}
extension HTTPRequest {
public var throttle: ThrottleOption {
get { self[option: ThrottleOption.self] }
set { self[option: ThrottleOption.self] = newValue }
}
}
回想一下,请求选项允许我们添加每个请求的行为。 因此,默认情况下,请求始终受到限制,但我们可以指示单个请求从不受到限制。 剩下的就是在我们的加载器中寻找它:
public override func load(task: HTTPTask) {
if task.request.throttle == .never {
super.load(task: task)
return
}
...
}
有了这个,我们现在有了一个加载程序,我们可以将其插入我们的链中以限制同时传出的网络请求的数量。 另请注意,我们已将 maximumNumberOfRequests 声明为公共变量,这意味着我们可以动态更新此值。 例如,我们的应用程序可能会下载一些配置设置以指示允许加载请求的速度。
// A networking chain that:
// - prevents you from resetting while a reset command is in progress
// - automatically cancels in-flight requests when asked to reset
// - limits the number of simultaneous requests to a maximum number
// - updates requests with missing server information with default or per-request server environment information
// - executes all of this on a URLSession
let chain = resetGuard --> autocancel --> throttle --> applyEnvironment --> ... --> urlSessionLoader
如果我们有这样的东西,我们可以远程“关闭”我们的行为不端的应用程序,而不必争先恐后地发布应用程序更新,并且我们可以保持我们的服务器正常运行。
在下一篇文章中,我们将研究如何在请求失败时自动重试请求。