🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

前言

聖誕快樂!🎅

大家在開發應用程式的過程中,有沒有過「在自己的設備上運行正常,但在其他設備上卻出現異常」的奇妙體驗呢?

在開發過程中,使用自己的模擬器進行正常運行的確認,卻收到QA團隊的回饋「運行不正常」...😱 經過調查發現,當多個非同步處理同時運作時,狀態在意想不到的時間更新,導致了意料之外的行為。這便是競爭條件(Race Condition)

競爭條件是什麼?

競爭條件指的是,由於多個處理的執行順序或完成時機不確定,導致產生意想不到的結果。

因為哪個處理先完成會隨時改變,所以這成為了每次執行結果都可能不同的麻煩問題。

競爭條件發生的典型模式:

  • 📝 對同一變數的競爭性更新(多個處理同時修改同一狀態)
  • 🔄 缺乏處理依賴關係(處理B在未等到處理A完成的情況下啟動)
  • ⏱️ 依賴時機的判定(在「尚未準備好」的狀態下進行檢查)
  • 🔓 同時存取資源(如檔案、DB、網路等)

本文將以聖誕應用程式為例,解釋作者實際體驗到的兩個競爭條件的案例。特別是,聚焦於Flutter應用開發中常見的「狀態管理」與「非同步處理的時機」。

有意識地重現競爭條件是困難的

其實,撰寫這篇文章時,我試圖在聖誕應用程式中重現競爭條件,但...失敗了😭

在認真實現的過程中,潛在的錯誤卻不容易被察覺,而當我試圖刻意製造錯誤時,卻總是無法做到。這就是競爭條件的麻煩之處。因為它依賴時機,狙擊重現變得非常困難。

在實際的應用開發中,以下的複雜因素交織在一起導致競爭條件的發生:

  • 多個API調用
  • 用戶認證處理
  • 設備權限檢查
  • 與本地DB的同步
  • 外部SDK的初始化
  • 推送通知或深度鏈接的處理

這些在複雜的實際環境中交織,會在意想不到的時機競爭非同步處理。

因此,預防是最重要的。本文將介紹預防競爭條件的設計原則,以及如果不幸發生競爭條件的檢測方法。

案例1: Future與Stream監聽器的競爭導致標誌的錯誤覆蓋

問題概述

假設在聖誕應用中實現了顯示來自聖誕老人的禮物對話框的功能。

  • 正常啟動:首次啟動時顯示禮物
  • SNS鏈接經由:若透過X或Instagram等SNS鏈接啟動,則直接顯示活動資訊,因此不顯示禮物

然而,多個非同步處理同時競爭更新相同的標誌,導致了意料之外的行為。

症狀:點擊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(不顯示禮物)
      });
    });
  }
}

競爭條件發生的理由

此實現中,以下兩個處理競爭更新同一標誌

  1. 處理A(Future):在啟動設置檢查完成後更新標誌(首次啟動設為true)
  2. 處理B(Stream監聽器):若經由SNS鏈接啟動則更新標誌(設為false)

※ 實際的深度鏈接實現中,啟動時已確定的initial link與啟動後收到的link(Stream)往往是分開的,需要考慮二者。

此處的問題在於無法知道哪一個處理會先完成。執行順序如下所示不定:

案例1(異常行為):處理B → 處理A的順序完成 ❌

1. 先執行Stream監聽器
   → _shouldShowGift = false(因為經由SNS鏈接應不顯示禮物)

2. Future的檢查處理完成
   → _shouldShowGift = true(因為是首次啟動應顯示禮物)← 覆蓋!

結果: 雖然經由SNS鏈接但卻顯示了禮物!😱

案例2(正常行為):處理A → 處理B的順序完成 ✅

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();
  });
});

修正要點:

  • 新增專用標誌以記錄是否經由SNS鏈接
  • 保護SNS鏈接設置的值,避免在後續處理中被覆蓋
  • 在更新標誌前檢查狀態
  • 透過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();
  }
}

案例2: 生命週期事件與PostFrameCallback的競爭

問題概述

在聖誕應用中,實現了用戶註冊完成後顯示歡迎訊息的功能。然而,判定處理在用戶註冊完成前執行,導致某些設備無法顯示該訊息。

症狀:用戶註冊完成後,本應顯示的「聖誕快樂!🎅」訊息在某些設備上未顯示😢

有問題的代碼

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. 處理A(Stream監聽器):接收用戶註冊完成事件並更新標誌
  2. 處理B(PostFrameCallback):在第一幀繪製後執行訊息的判定

這是「在準備完成前進行檢查」的問題。執行順序因設備的處理速度而變化:

案例1(某個執行時機):處理B → 處理A的順序執行 ❌

1. PostFrameCallback先執行
   → 此時_isSignUpCompleted = false
   → 判定訊息時跳過

2. 註冊完成事件隨後到達
   → _isSignUpCompleted = true 設置

結果: 歡迎訊息未顯示(判定時仍為false)

案例2(不同執行時機):處理A → 處理B的順序執行 ✅

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);
            });
          }
        }
      }),
    );
  }
}

修正要點:

  • 明確處理的依賴關係(「用戶註冊完成」→「顯示訊息的判定」)
  • 在事件驅動下執行處理,排除時機的不確定性
  • 將PostFrameCallback置於事件接收後,以保持依賴關係,同時等候幀繪製
  • 透過二次的mounted檢查防止Widget銷毀後的處理
  • 使用_didShowWelcome標誌防止事件多次觸發引起的重複顯示

※ PostFrameCallback本身並非壞事。對於需要「首次build後」顯示的UI(如showDialog等)的情況,是有效的手段。此次問題在於將「依賴於用戶註冊完成」的處理放在了可能在事件前執行的地方。將PostFrameCallback使用在事件接收,依賴關係便可保留。


防止競爭條件的設計原則

1. 遵守單一責任原則

設計時儘量確保一個標誌或狀態僅能被一個處理更新。

不良範例:

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;

2. 明確依賴關係

當處理B依賴於處理A的完成時,需在代碼中明確該依賴關係。

// 不良範例:依賴關係不明確
void initState() {
  _processA();  // 何時完成?
  _processB();  // 依賴於處理A,但順序未保證
}

// 良好範例:明確依賴關係
void initState() {
  _processA().then((_) {
    _processB();  // 在處理A完成後必定執行
  });
}

3. 利用事件驅動架構

時機依賴的處理應使用事件驅動執行。

// 不良範例:依賴於時機
WidgetsBinding.instance.addPostFrameCallback((_) {
  if (_someCondition) {  // 此條件何時會為true不明
    _doSomething();
  }
});

// 良好範例:事件驅動
_eventStream.listen((event) {
  if (event == EventType.target) {
    _doSomething();  // 在事件發生時確保執行
  }
});

4. 保護不變條件

一旦設置的重要狀態,應防止其被隨意覆蓋。

void updateFlag(bool newValue) {
  // 若已設置重要狀態則保護
  if (_criticalFlag) {
    return;  // 防止覆蓋
  }
  setState(() {
    _criticalFlag = newValue;
  });
}

※ 在非同步處理中,Future完成時可能存在Widget已經dispose的情況,因此也別忘了進行如if (!mounted) return;等mounted檢查,或者在dispose中取消StreamSubscription。

5. 統一狀態的初始化時機

若存在多個非同步初始化處理,使用Future.wait()等候其完成。

@override
void initState() {
  super.initState();
  _initialize();
}

Future<void> _initialize() async {
  // 在所有初始化完成後再進行下一步
  await Future.wait([
    _initializeA(),
    _initializeB(),
    _initializeC(),
  ]);

  // 所有處理完成後進行判定
  _performCheck();
}

除錯與競爭條件的檢測方法 🔍

競爭條件的再現性低,除錯困難。如開篇所述,刻意重現競爭條件很難。因此,建置以下的方式檢測「察覺」將是重要的。

1. 在多設備與多構建模式下測試(最重要!)

這是最有效的檢測方法。務必在多部實機上進行測試:

  • ✅ 確認調試編譯和發佈編譯的運行
  • ✅ 測試低規格設備與高規格設備
  • ✅ 在模擬器和實機上進行測試

「因為在我的設備上一切正常,所以沒問題」的判斷是危險的。競爭條件的行為會隨設備而異。

2. 當多部設備測試困難時的替代手段

若無法準備多部實機,可以使用以下方法檢測競爭條件。

2-1. 透過日誌可視化執行順序

_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

2-2. 在調試構建中刻意加入延遲

_processA().then((_) async {
  if (kDebugMode) {
    await Future.delayed(Duration(seconds: 2));  // 刻意延遲
  }
  setState(() => _flagA = true);
});

透過加入延遲可以改變時機進行測試。若加入延遲後行為改變,則極有可能存在競爭條件。

3. 請AI協助檢閱

實現階段預防競爭條件的另一个好方法,就是使用AI代碼檢閱,例如Claude等工具。

請從競爭條件的角度檢閱這段代碼。
若有多個非同步處理更新相同狀態的情況,請指出來。

AI能找到人類容易忽視的模式。當代碼撰寫完畢後立即協助檢閱,可以在漏洞混入之前進行對策。


總結

競爭條件是應用開發中的「隱形敵人」。它具備以下特徵:

  • 再現性低:因設備規格或時機的不同可能發生或不發生
  • 原因追蹤困難:在開發環境中看似正確運行
  • 影響嚴重:可能大幅損害用戶體驗
  • 刻意製造也難:狙擊重現非常困難(筆者此次也遭遇失敗了😭)

預防最為重要!

為了不小心混入漏洞:

  1. 避免多個處理更新同一狀態的設計
  2. 明確處理的依賴關係
  3. 以事件驅動方式執行處理
  4. 保護重要的狀態

如果混入了怎麼辦?

需要建立察覺的機制:

  1. 🔍 在多部實機上測試(調試/發佈,低/高規格)
  2. 🔍 請AI進行代碼檢閱
  3. 🔍 透過日誌可視化執行順序
  4. 🔍 刻意加入延遲進行測試

遵守這些原則可以大幅減少競爭條件的風險。

祝大家的應用程式能在所有設備上正常運行!🎄✨

愉快編碼,聖誕快樂!🎅🎁


原文出處:https://qiita.com/natsuko_nagaoka/items/b0de963db4d181c3bd67


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝11   💬9   ❤️5
330
🥈
我愛JS
📝1   💬8   ❤️2
62
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付