聖誕快樂!🎅
大家在開發應用程式的過程中,有沒有過「在自己的設備上運行正常,但在其他設備上卻出現異常」的奇妙體驗呢?
在開發過程中,使用自己的模擬器進行正常運行的確認,卻收到QA團隊的回饋「運行不正常」...😱 經過調查發現,當多個非同步處理同時運作時,狀態在意想不到的時間更新,導致了意料之外的行為。這便是競爭條件(Race Condition)。
競爭條件指的是,由於多個處理的執行順序或完成時機不確定,導致產生意想不到的結果。
因為哪個處理先完成會隨時改變,所以這成為了每次執行結果都可能不同的麻煩問題。
競爭條件發生的典型模式:
本文將以聖誕應用程式為例,解釋作者實際體驗到的兩個競爭條件的案例。特別是,聚焦於Flutter應用開發中常見的「狀態管理」與「非同步處理的時機」。
其實,撰寫這篇文章時,我試圖在聖誕應用程式中重現競爭條件,但...失敗了😭
在認真實現的過程中,潛在的錯誤卻不容易被察覺,而當我試圖刻意製造錯誤時,卻總是無法做到。這就是競爭條件的麻煩之處。因為它依賴時機,狙擊重現變得非常困難。
在實際的應用開發中,以下的複雜因素交織在一起導致競爭條件的發生:
這些在複雜的實際環境中交織,會在意想不到的時機競爭非同步處理。
因此,預防是最重要的。本文將介紹預防競爭條件的設計原則,以及如果不幸發生競爭條件的檢測方法。
假設在聖誕應用中實現了顯示來自聖誕老人的禮物對話框的功能。
然而,多個非同步處理同時競爭更新相同的標誌,導致了意料之外的行為。
症狀:點擊SNS鏈接打開應用後,本應不顯示的禮物對話框卻出現(或反之,本應顯示卻未顯示)😱
class ChristmasScreenState extends State<ChristmasScreen> {
bool _shouldShowGift = false;
@override
void initState() {
super.initState();
// 處理A: 透過Future進行初始化(可能需要花時間)
_checkLaunchSettings().then((_) {
setState(() {
_shouldShowGift = _calculateShouldShow();
});
});
// 處理B: 透過Stream監聽進行監控(可能立刻執行)
_deepLinkHandler.onLinkReceived.listen((link) {
setState(() {
_shouldShowGift = false; // 若經由SNS鏈接則為false(不顯示禮物)
});
});
}
}
此實現中,以下兩個處理競爭更新同一標誌:
※ 實際的深度鏈接實現中,啟動時已確定的initial link與啟動後收到的link(Stream)往往是分開的,需要考慮二者。
此處的問題在於無法知道哪一個處理會先完成。執行順序如下所示不定:
1. 先執行Stream監聽器
→ _shouldShowGift = false(因為經由SNS鏈接應不顯示禮物)
2. Future的檢查處理完成
→ _shouldShowGift = true(因為是首次啟動應顯示禮物)← 覆蓋!
結果: 雖然經由SNS鏈接但卻顯示了禮物!😱
1. Future的檢查處理完成
→ _shouldShowGift = true(因為是首次啟動應顯示禮物)
2. Stream監聽器執行
→ _shouldShowGift = false(因為經由SNS鏈接應不顯示禮物)← 覆蓋!
結果: 正確地因經由SNS鏈接不顯示禮物 ✅
由於設備的性能、執行緒狀態、網路等因素,處理A的完成時機會有所不同,導致某些設備運行正常,而某些設備卻會出現異常行為,這是一個非常麻煩的漏洞。在環境快速的情況下,順序穩定且看起來是正常的,而在環境緩慢或負載高的情況下,順序變化導致漏洞顯現。
如果標誌已被設置,則在後續處理中不應該覆蓋其設置,需要加入保護條件。
// 若SNS鏈接監聽器已設置為false,則不在其他處理中覆蓋
bool _isFromDeepLink = false;
_deepLinkHandler.onLinkReceived.listen((link) {
setState(() {
_shouldShowGift = false;
_isFromDeepLink = true; // 記錄經由SNS鏈接
});
});
_checkLaunchSettings().then((_) {
// 若Widget已被銷毀,或經由SNS鏈接則跳過
if (!mounted || _isFromDeepLink) {
return; // 若已經經由SNS鏈接設置為false則不覆蓋
}
setState(() {
_shouldShowGift = _calculateShouldShow();
});
});
修正要點:
mounted檢查防止Widget銷毀後的setState重新思考設計使多個非同步處理更新相同狀態的情況也很重要:
class ChristmasScreenState extends State<ChristmasScreen> {
bool _isFromDeepLink = false; // 專用於SNS鏈接的標誌
bool _isFirstLaunch = false; // 專用於首次啟動的標誌
// SNS鏈接經由則不顯示禮物,其他情況下首次啟動則顯示禮物
bool get shouldShowGift => !_isFromDeepLink && _isFirstLaunch;
@override
void initState() {
super.initState();
// 每個處理更新獨立標誌
_checkLaunchSettings().then((_) {
if (!mounted) return;
setState(() {
_isFirstLaunch = _calculateIsFirstLaunch();
});
});
_deepLinkHandler.onLinkReceived.listen((link) {
if (!mounted) return;
setState(() {
_isFromDeepLink = true;
});
});
}
}
這種設計可以避免多個非同步處理更新同一個標誌的結構,使處理順序的依賴問題變得更容易防範。
※ 在實際實現中,別忘了也要處理StreamSubscription的銷毀:
class ChristmasScreenState extends State<ChristmasScreen> {
late final StreamSubscription _deepLinkSubscription;
@override
void initState() {
super.initState();
_deepLinkSubscription = _deepLinkHandler.onLinkReceived.listen((link) {
if (!mounted) return;
setState(() {
_isFromDeepLink = true;
});
});
}
@override
void dispose() {
_deepLinkSubscription.cancel();
super.dispose();
}
}
在聖誕應用中,實現了用戶註冊完成後顯示歡迎訊息的功能。然而,判定處理在用戶註冊完成前執行,導致某些設備無法顯示該訊息。
症狀:用戶註冊完成後,本應顯示的「聖誕快樂!🎅」訊息在某些設備上未顯示😢
class ChristmasScreenState extends State<ChristmasScreen> {
bool _isSignUpCompleted = false;
@override
void initState() {
super.initState();
// 處理A: 用戶註冊完成事件的監聽器
_subscriptions.add(
_setupNotifier.events.listen((event) {
if (event == EventType.signUpComplete) {
_isSignUpCompleted = true; // 更新標誌
}
}),
);
// 處理B: 使用PostFrameCallback執行歡迎訊息的判定
WidgetsBinding.instance.addPostFrameCallback((_) {
_showWelcomeMessage(context);
});
}
void _showWelcomeMessage(BuildContext context) {
// 僅在_isSignUpCompleted為true的情況下顯示訊息
if (!_isSignUpCompleted) {
return; // 若註冊初次完成則跳過
}
// 顯示歡迎訊息的處理...
}
}
這個實現中,以下兩個處理的執行順序並未得到保障:
這是「在準備完成前進行檢查」的問題。執行順序因設備的處理速度而變化:
1. PostFrameCallback先執行
→ 此時_isSignUpCompleted = false
→ 判定訊息時跳過
2. 註冊完成事件隨後到達
→ _isSignUpCompleted = true 設置
結果: 歡迎訊息未顯示(判定時仍為false)
1. 註冊完成事件先到達
→ _isSignUpCompleted = true
2. PostFrameCallback執行
→ 檢查_isSignUpCompleted = true
→ 顯示訊息
結果: 歡迎訊息正常顯示
將訊息判定的時機改為接收到用戶註冊完成事件時。
class ChristmasScreenState extends State<ChristmasScreen> {
bool _isSignUpCompleted = false;
bool _didShowWelcome = false; // 防止重複顯示的標誌
@override
void initState() {
super.initState();
// 在用戶註冊完成事件接收的時候進行訊息判定
_subscriptions.add(
_setupNotifier.events.listen((event) {
if (event == EventType.signUpComplete) {
_isSignUpCompleted = true;
// 若已有顯示過則跳過(防止重複顯示)
if (_didShowWelcome) return;
_didShowWelcome = true;
// 在用戶註冊完成後,幀繪製後判定顯示歡迎訊息
if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_showWelcomeMessage(context);
});
}
}
}),
);
}
}
修正要點:
mounted檢查防止Widget銷毀後的處理_didShowWelcome標誌防止事件多次觸發引起的重複顯示※ PostFrameCallback本身並非壞事。對於需要「首次build後」顯示的UI(如showDialog等)的情況,是有效的手段。此次問題在於將「依賴於用戶註冊完成」的處理放在了可能在事件前執行的地方。將PostFrameCallback使用在事件接收後,依賴關係便可保留。
設計時儘量確保一個標誌或狀態僅能被一個處理更新。
不良範例:
bool _shouldShowDialog = false;
// 多個處理更新同一標誌
_processA().then((_) => _shouldShowDialog = true);
_processB().then((_) => _shouldShowDialog = false);
_streamC.listen((_) => _shouldShowDialog = true);
良好範例:
bool _isProcessAComplete = false;
bool _isProcessBComplete = false;
bool _isProcessCTriggered = false;
// 各處理更新獨立的標誌
bool get shouldShowDialog => _isProcessAComplete && !_isProcessBComplete && _isProcessCTriggered;
當處理B依賴於處理A的完成時,需在代碼中明確該依賴關係。
// 不良範例:依賴關係不明確
void initState() {
_processA(); // 何時完成?
_processB(); // 依賴於處理A,但順序未保證
}
// 良好範例:明確依賴關係
void initState() {
_processA().then((_) {
_processB(); // 在處理A完成後必定執行
});
}
時機依賴的處理應使用事件驅動執行。
// 不良範例:依賴於時機
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_someCondition) { // 此條件何時會為true不明
_doSomething();
}
});
// 良好範例:事件驅動
_eventStream.listen((event) {
if (event == EventType.target) {
_doSomething(); // 在事件發生時確保執行
}
});
一旦設置的重要狀態,應防止其被隨意覆蓋。
void updateFlag(bool newValue) {
// 若已設置重要狀態則保護
if (_criticalFlag) {
return; // 防止覆蓋
}
setState(() {
_criticalFlag = newValue;
});
}
※ 在非同步處理中,Future完成時可能存在Widget已經dispose的情況,因此也別忘了進行如if (!mounted) return;等mounted檢查,或者在dispose中取消StreamSubscription。
若存在多個非同步初始化處理,使用Future.wait()等候其完成。
@override
void initState() {
super.initState();
_initialize();
}
Future<void> _initialize() async {
// 在所有初始化完成後再進行下一步
await Future.wait([
_initializeA(),
_initializeB(),
_initializeC(),
]);
// 所有處理完成後進行判定
_performCheck();
}
競爭條件的再現性低,除錯困難。如開篇所述,刻意重現競爭條件很難。因此,建置以下的方式檢測「察覺」將是重要的。
這是最有效的檢測方法。務必在多部實機上進行測試:
「因為在我的設備上一切正常,所以沒問題」的判斷是危險的。競爭條件的行為會隨設備而異。
若無法準備多部實機,可以使用以下方法檢測競爭條件。
_processA().then((_) {
print('[DEBUG] Process A completed at ${DateTime.now()}');
setState(() => _flagA = true);
});
_processB().then((_) {
print('[DEBUG] Process B completed at ${DateTime.now()}');
setState(() => _flagB = true);
});
透過日誌確認執行順序,可以產生「處理B先於處理A完成!」的察覺。
實際輸出例:
[DEBUG] Process B completed at 2025-12-17 10:23:45.123
[DEBUG] Process A completed at 2025-12-17 10:23:45.156
_processA().then((_) async {
if (kDebugMode) {
await Future.delayed(Duration(seconds: 2)); // 刻意延遲
}
setState(() => _flagA = true);
});
透過加入延遲可以改變時機進行測試。若加入延遲後行為改變,則極有可能存在競爭條件。
實現階段預防競爭條件的另一个好方法,就是使用AI代碼檢閱,例如Claude等工具。
請從競爭條件的角度檢閱這段代碼。
若有多個非同步處理更新相同狀態的情況,請指出來。
AI能找到人類容易忽視的模式。當代碼撰寫完畢後立即協助檢閱,可以在漏洞混入之前進行對策。
競爭條件是應用開發中的「隱形敵人」。它具備以下特徵:
為了不小心混入漏洞:
需要建立察覺的機制:
遵守這些原則可以大幅減少競爭條件的風險。
祝大家的應用程式能在所有設備上正常運行!🎄✨
愉快編碼,聖誕快樂!🎅🎁
原文出處:https://qiita.com/natsuko_nagaoka/items/b0de963db4d181c3bd67