Swift并发如何防止线程爆炸?
hudson 译 原文
几周前,我阅读了Wojciech Kulik 的一篇文章,他在文章中谈到了Swift并发框架中的一些陷阱。在其中一个部分中,Wojciech简要提到了线程爆炸,以及Swift并发如何通过限制我们过度提交比CPU内核更多的线程来防止其发生。
这让我想知道......真的是这样吗?这在幕后是如何运作的?我们能以某种方式欺骗系统来创建比CPU内核更多的线程吗?
我们将在这篇文章中回答所有这些问题。所以不用多说,让我们直接跳进去。
了解线程爆炸💥
那么,什么是线程爆炸?线程爆炸是指大量线程在系统中同时运行,最终导致性能问题和内存开销的情况。
对于有多少线程被认为太多,没有明确的答案。作为一般基准,我们可以参考WWDC视频中给出的示例,即运行线程比其CPU内核多16倍的系统被视为正在经历线程爆炸。
由于Grand Central Dispatch(GCD)没有防止线程爆炸的内置机制,因此使用调度队列创建线程很容易。考虑以下代码:
final class HeavyWork {
static func dispatchGlobal(seconds: UInt32) {
DispatchQueue.global(qos: .background).async {
sleep(seconds)
}
}
}
// Execution:
for _ in 1...150 {
HeavyWork.dispatchGlobal(seconds: 3)
}
一旦执行,上面的代码将总共产生150个线程,导致线程爆炸。 这可以通过暂停执行和检查调试导航器来验证。
现在您已经学会了如何触发线程爆炸,让我们尝试使用Swift 并发执行相同的代码,看看会发生什么。
Swift并发如何管理线程
众所周知,Swift并发中有3个级别的任务优先级,主要是userInitiated、utility 和background ,其中userInitiated具有最高优先级,其次是优先级较低的utility 和background。 因此,让我们继续相应地更新我们的HeayWork类:
class HeavyWork {
static func runUserInitiatedTask(seconds: UInt32) {
Task(priority: .userInitiated) {
print(“🥸 userInitiated: \(Date())”)
sleep(seconds)
}
}
static func runUtilityTask(seconds: UInt32) {
Task(priority: .utility) {
print(“☕️ utility: \(Date())”)
sleep(seconds)
}
}
static func runBackgroundTask(seconds: UInt32) {
Task(priority: .background) {
print(“⬇️ background: \(Date())”)
sleep(seconds)
}
}
}
每次创建任务时,我们都会打印出创建时间。然后,我们可以用它来可视化幕后发生的事情。
随着更新的HeavyWork类的到位,让我们开始第一次测试。
测试1:创建具有相同优先级的任务
这个测试与我们之前看到的调度队列示例基本相同,但我们不会使用GCD,而是使用Swift 并发中的任务来创建线程。
// Test 1: Creating Tasks with Same Priority Level
for _ in 1...150 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
以下是从Xcode控制台捕获的日志。
正如您所看到的(从任务创建时间来看),当线程数达到6时,线程的创建停止,这与我的6核iPhone 12的CPU内核数量完全匹配。只有在其中一个正在运行的任务完成执行后,任务的创建才会继续。因此,一次最多只能同时运行6个线程。
注意:
无论所选设备如何,iOS模拟器将始终将最大线程数限制为1。因此,请确保使用真实设备运行上述测试,以获得更准确的结果。
为了更清楚地了解幕后到底发生了什么,让我们暂停执行。
似乎我们刚才看到的一切都由一个名为**“com.apple.root.user-initiated-qos.cooperative”**的并发队列控制。
根据上述观察,可以肯定地说,这就是Swift 并发防止线程爆炸发生的方式:有一个专用的并发队列来限制线程的最大数量,这样它就不会超过CPU内核。
测试2:同时创建从高优先级到低优先级的任务
现在,让我们通过在测试中添加具有不同优先级的任务来更深入地了解。
// Test 2: Creating Tasks from High to Low Priority Level All at Once
for _ in 1...30 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runUtilityTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runBackgroundTask(seconds: 3)
}
请注意,我们首先创建优先级最高(userInitiated)的任务,然后是utility和backround。根据我们之前的观察,我预计会看到3个队列,每个队列中有6个线程同时运行,这意味着我们将看到总共18个线程被生成。令人惊讶的是,情况并非如此。看看以下屏幕截图:
如您所见,当高优先级队列(userInitiated)饱和时,utility和backround 队列都将允许的最大线程限制为1。换句话说,我们在这个测试中可以拥有的最大线程数是8。
这是一个非常有趣的发现!饱和高优先级队列将以某种方式抑制其他低优先级队列产生更多线程。
但是,如果我们颠倒优先级的顺序,会发生什么?让我们来了解一下!
测试3:一次性创建从低优先级到高优先级的任务
首先,让我们更新执行代码:
// Test 3: Creating Tasks from Low to High Priority Level All at Once
for _ in 1...30 {
HeavyWork.runBackgroundTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runUtilityTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
结果来了:
我们得到的结果与“测试2”完全相同。
系统似乎足够聪明,可以让优先级更高的任务首先运行,即使我们先启动优先级低的任务。 除此之外,系统仍然限制我们创建超过8个并发线程,因此我们仍然无法为此测试创建线程爆炸。 干得好,苹果! 👍🏻
测试4:创建从低优先级到高优先级的任务,中间休息
在现实生活中,我们不太可能同时开始一堆具有不同优先级的任务。 因此,让我们通过在每个for循环之间添加一个小小的睡眠来创建一个更现实的条件。 请注意,我们仍然使用从低到高的顺序。
// Test 4: Creating Tasks from Low to High Priority Level with Break in Between
for _ in 1...30 {
HeavyWork.runBackgroundTask(seconds: 3)
}
sleep(3)
print(“⏰ 1st break...”)
for _ in 1...30 {
HeavyWork.runUtilityTask(seconds: 3)
}
sleep(3)
print(“⏰ 2nd break...”)
for _ in 1...30 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
我们得到的结果非常有趣。
如您所见,在第二次中断后,所有3个队列都在运行多个线程。看起来,如果我们先启动低优先级队列并让它运行一段时间,高优先级队列不会抑制低优先级队列的性能。
我已经执行过几次这个测试了,最大线程数量可能会略有不同,但它或多或少等于CPU核心的3倍。
这被认为是线程爆炸吗?
我不这么认为,因为比CPU内核多3倍的线程仍然比我之前提到的16倍阈值少得多。事实上,我认为苹果故意允许这种情况发生,以便在执行性能和多线程开销之间取得更好的平衡。如果你有其他观点,请在推特上联系我,我真的很想听听你的想法。
小结
Swift并发在防止线程爆炸方面做得很好,但我们不能否认,如果我们不断使userInitiated 队列饱和,它将导致非常严重的瓶颈。
根据我们在“测试4”中得到的结果,可以肯定地说,我们应该更频繁地使用background 和utility队列,并且仅在必要时使用userInitiated队列。
想试用示例代码吗? 可在这里获取!
感谢您的阅读。👨🏻💻