51
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutterにおける非同期処理のレースコンディションとその対策

Last updated at Posted at 2025-12-23

はじめに

メリークリスマス!🎅

皆さんは、アプリ開発中に「自分の端末では正常に動くのに、別の端末では動作がおかしい」という不思議な体験をしたことはありませんか?

開発中は自分のエミュレータで動作確認をして問題なかったのに、QAチームから「動作がおかしい」という指摘が...😱 調査してみると、非同期処理が複数走っているときに、予期しないタイミングで状態が更新されることで意図しない動作を引き起こしていました。これが レースコンディション です。

レースコンディションって何?

レースコンディションとは、複数の処理の実行順序や完了タイミングが不確定なために、意図しない結果が生じる問題です。

どちらの処理が先に完了するかはその時々で変わるため、実行するたびに結果が変わるという厄介な問題になります。

レースコンディションが起こる代表的なパターン:

  • 📝 同じ変数への競合的な更新(複数の処理が同じ状態を書き換える)
  • 🔄 処理の依存関係の欠如(処理Bが処理Aの完了を待たずに実行される)
  • ⏱️ タイミングに依存する判定(「まだ準備ができていない」状態でチェックしてしまう)
  • 🔓 リソースへの同時アクセス(ファイル、DB、ネットワークなど)

本記事では、実際に筆者が体験した2つのレースコンディションの事例を、クリスマスアプリを例にしながら解説していきます。特に、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(ギフト非表示)
      });
    });
  }
}

レースコンディションが発生する理由

この実装では、以下の2つの処理が 同一のフラグ を競合して更新します:

  1. 処理A(Future): 起動設定チェックの完了後にフラグを更新(初回起動ならtrue)
  2. 処理B(Stream リスナー): SNSリンクから起動した場合にフラグを更新(false)

※ 実際の deep link 実装では、起動時に既に確定している 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;  // 初回登録未完了ならスキップ
    }
    // ウェルカムメッセージ表示処理...
  }
}

レースコンディションが発生する理由

この実装では、以下の2つの処理の実行順序が保証されていません:

  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 自体が悪いわけではありません。UI 表示(showDialog など)が「初回 build 後」を要求するケースでは有効な手段です。今回の問題は、「ユーザー登録完了」というイベントに依存する処理を、イベントより先に実行される可能性がある場所 に PostFrameCallback を置いてしまった点にあります。イベント受信 に PostFrameCallback を使えば、依存関係が保たれます。


レースコンディションを防ぐための設計原則

1. 単一責任の原則を守る

1つのフラグや状態は、できるだけ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 チェックや、StreamSubscription の破棄(dispose での cancel)も忘れずに行いましょう。

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にレビューしてもらう

実装段階でレースコンディションを未然に防ぐ方法として、Claudeなどを使ったAIコードレビューが非常に便利です。

このコードをレースコンディションの観点でレビューしてください。
複数の非同期処理が同じ状態を更新している箇所があれば指摘してください。

AIは人間が見落としがちなパターンを見つけてくれることがあります。コードを書いた直後にレビューを依頼することで、バグが混入する前に対策できます。


まとめ

レースコンディションは、アプリ開発における「見えない敵」です。以下のような特徴があります:

  • 再現性が低い: 端末スペックやタイミングにより発生したりしなかったりする
  • 原因特定が困難: 開発環境では正しく動作しているように見える
  • 深刻な影響: ユーザー体験を大きく損なう可能性がある
  • 意図的に作るのも難しい: 狙って再現するのが困難(筆者も今回挫折しました😭)

予防が最重要!

意図せずバグを混入させないために:

  1. 複数の処理が同じ状態を更新する設計を避ける
  2. 処理の依存関係を明確にする
  3. イベント駆動で処理を実行する
  4. 重要な状態は保護する

もし混入してしまったら?

気づくための仕組みを作る:

  1. 🔍 複数の実機でテストする(デバッグ/リリース、低/高スペック)
  2. 🔍 AIにコードレビューしてもらう
  3. 🔍 ログで実行順序を可視化する
  4. 🔍 意図的に遅延を入れてテストする

これらの原則を守ることで、レースコンディションのリスクを大幅に減らすことができます。

皆さんのアプリが、すべての端末で正しく動きますように!🎄✨

Happy Coding & Merry Christmas! 🎅🎁

51
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
51
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?