You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Swift异步序列偶发丢失@Published属性更新问题排查与修复

偶发@Published异步序列更新丢失问题分析与修复

问题背景

测试中出现偶发挂起失败:两个异步序列同时监听并修改同一个@Published属性fire,预期序列能接收所有属性更新以保证测试稳定,但运行约10万次内会出现测试无法完成的情况。即使给类添加@MainActor确保单线程执行,问题仍存在。

成功测试控制台输出:

createStream1(): true
createStream2(): true
createStream1(): false

失败测试控制台输出:

createStream1(): true
createStream2(): true
createStream1(): true

相关代码片段:

final class Tests: XCTestCase {
    @MainActor func test_SUTStream1() async {
        await SUT().toTest()
    }
}

@MainActor
class SUT {
    @Published private(set) var fire = false

    init() {}

    func toTest() async {
        let cancelable = Task {
            await createStream2()
        }
        await createStream1()
        cancelable.cancel()
    }

    private func createStream1() async {
        fire = true

        for await didFire in $fire.values {
            print("\(#function): \(didFire)")
            if didFire == false {
                break
            }
        }
    }

    private func createStream2() async {
        for await didFire in $fire.values {
            print("\(#function): \(didFire)")
            if didFire == true {
                fire = false
                break
            }
        }
    }
}

1. 异步序列偶发丢失@Published属性更新的原因

  • 冷异步序列的订阅时序问题:$fire.values是冷序列,只有执行for await迭代时才会开始订阅属性变化。失败场景中,createStream1先设置fire = true再启动迭代;createStream2任务启动稍晚,开始订阅时fire已是true,因此收到该初始值并设置fire = false。但此时createStream1的迭代可能因@Published内部投递机制的时序问题,没接收到false的更新,反而重复收到旧的true值,导致循环无法退出。
  • 单线程下的异步任务调度竞态:虽然@MainActor保证单线程执行,但异步任务的启动、订阅、事件投递的细微时序差异依然会引发问题。当createStream2设置fire = false时,createStream1的迭代可能还在处理之前的true值,导致后续的false更新被遗漏。
  • 任务取消逻辑的依赖问题:toTest方法必须等createStream1完成后才会取消createStream2的任务,一旦createStream1因没收到false更新而挂起,整个测试就会卡死。

2. 问题修复方案

方案一:先完成订阅再触发属性变化

调整逻辑,确保两个异步序列都完成订阅后再修改fire,避免冷序列订阅滞后导致的更新丢失:

@MainActor
class SUT {
    @Published private(set) var fire = false

    init() {}

    func toTest() async {
        await withTaskGroup(of: Void.self) { group in
            // 同时启动两个序列的订阅
            group.addTask { await self.createStream1() }
            group.addTask { await self.createStream2() }
            
            // 等待一个主队列事件循环,确保订阅完成
            DispatchQueue.main.async {
                self.fire = true
            }
            
            await group.waitForAll()
        }
    }

    private func createStream1() async {
        for await didFire in $fire.values {
            print("\(#function): \(didFire)")
            if didFire == false {
                break
            }
        }
    }

    private func createStream2() async {
        for await didFire in $fire.values {
            print("\(#function): \(didFire)")
            if didFire == true {
                fire = false
                break
            }
        }
    }
}

方案二:用CurrentValueSubject替代@Published

@Published基于CurrentValueSubject封装,但直接使用CurrentValueSubject能更精确控制事件投递,减少内部机制的不确定性:

import Combine

@MainActor
class SUT {
    private let _fire = CurrentValueSubject<Bool, Never>(false)
    var fire: Bool {
        get { _fire.value }
        set { _fire.send(newValue) }
    }

    init() {}

    func toTest() async {
        let cancelable = Task {
            await createStream2()
        }
        await createStream1()
        cancelable.cancel()
    }

    private func createStream1() async {
        fire = true

        for await didFire in _fire.values {
            print("\(#function): \(didFire)")
            if didFire == false {
                break
            }
        }
    }

    private func createStream2() async {
        for await didFire in _fire.values {
            print("\(#function): \(didFire)")
            if didFire == true {
                fire = false
                break
            }
        }
    }
}

方案三:添加订阅同步等待点

在createStream1设置fire = true后,添加短暂等待,确保createStream2有足够时间完成订阅:

private func createStream1() async {
    fire = true
    // 等待1ms,确保其他订阅者完成订阅(可根据实际调整时长)
    try? await Task.sleep(nanoseconds: 1_000_000)

    for await didFire in $fire.values {
        print("\(#function): \(didFire)")
        if didFire == false {
            break
        }
    }
}

内容的提问来源于stack exchange,提问作者user27165230

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 01:01:15