🧵

[swift] メインスレッドから処理を逃すために Task.detached を使う必要はない(ことが多い)

2023/04/18に公開

TL;DR

  • Task.detachedTask.init と違って優先度 / task local values / actor context を受け継がないという性質がある
  • Task.detached は処理をメインスレッドから逃すために使われることがあるが、 async 関数はあえて main actor に isolate しない限り必ずバックグラウンドで実行されるため、ほとんどの場面ではこの用途で Task.detached を使う必要はない

Task.initTask.detached

Swift Concurrnecy において Task を作成する方法には

  • Task.init
  • Task.detached
  • async let
  • task group

があります。これらの使い分けについては、 Explore structured concurrency in Swift - WWDC21 の後半に出てくる表によくまとまっています。

async let と task group はそれぞれ使う場面が明確ですが、それらと比較すると Task.initTask.detached は使う場面が似ています。これらの違いは上記の図のように、 Task.init は Task 生成元の

  • 優先度
  • task locals values
  • actor isolation

を受け継ぐという点と、表にはありませんが、 Task.init は暗黙のうちに self をキャプチャする のに対して Task.detached はしないという点があると思います。

Task.detached を使いたい場面にはどういうものがあるでしょうか?自分は、これまで優先度や task local values をとにかく受け継ぎたくないという場面には出会ったことがありません。特定の優先度や task local values が必要であれば自分で指定できるためです。また、暗黙の self キャプチャをしたくないという理由だけで Task.detached を使うということも考えづらいので、 Task.detached を使うのは actor isolation を受け継ぎたくない、より具体的には main actor を受け継ぎたくないという場面のみで使っていました。

メインスレッドから処理を逃すための Task.detached

例えば、 UIViewController からメインスレッドが必要ない処理を実行する場合を考えます。 UIViewController は main actor なので、何か指定をしない限りメソッド内の処理はメインスレッドで実行されます。

// 暗黙のうちに @MainActor
final class MyViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
		
    // ここに書く処理はメインスレッドで実行される
  }
}

メインスレッドが占有されると UI のレスポンスが悪くなる可能性があるので、メインスレッドで行う必要がない UI とは関係ない処理、とくにその中でも重い処理はメインスレッドでは実行したくありません。そのような処理を Task.detached の中で行うことで、バックグラウンドスレッドに逃すことができます。

final class MyViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
		
    Task {
      // Task.init は main actor を受け継ぐので、
      // ここに書く処理はメインスレッドで実行される
    }
		
    Task.detached {
      // Task.detached は main actor を受け継がないので、
      // ここに書く処理はバックグラウンドスレッドで実行される
    }
  }
}

しかし、最近見た Your Brain 🧠 on Swift Concurrency - iOS Conf SG 2023 というトークにて、(大まかにまとめると)メインスレッドから処理を逃すためだけに Task.detached を使う必要はないということが述べられていて、その通りだなと思ったのでこれについて詳しく見ていきます。

async 関数の呼び出し

async 関数はあえて main actor に isolate されていない限り必ずバックグラウンドスレッドで実行されます。これは、 actor に isolate されていない async 関数は、 default concurrent executor によってバックグラウンドで実行されるためです。 actor は default serial executor により実行されますが、これもバックグラウンドで動作します。
この性質は、 async 関数がどこから実行されるかに関係なく成り立ちます。つまりメインスレッド / main actor から async 関数が実行された場合でも、 async 関数に入った時点でバックグラウンドスレッドに切り替わるということです。そのため、 UIViewController 内で Task.init で生成されることにより main actor を受け継いだ Task の中であっても、その中で実行された async 関数は必ずバックグラウンドスレッドで実行されることになります。

func someAsyncFunction() async {
  // main actor に isolate されていない async 関数なので、
  // どこから呼ばれたかに関わらずバックグラウンドで実行される 
}

final class MyViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
    
    Task {
      // ここは main actor だが、 someAsyncfunction の中身は
      // バックグラウンドスレッドで実行される
      await someAsyncFunction() 
    }
  }
}

以上により、 async 関数をバックグラウンドで実行するために Task.detached を使う必要はないことがわかります。

sync 関数の呼び出し

続いて sync 関数の呼び出しについても考えてみます。async 関数が呼び出し元に関係なくバックグラウンドスレッドで実行されたのに対して、 sync 関数がどのスレッドで実行されるかは呼び出し元のスレッドに依存します。例えば、メインスレッドから呼ばれた sync 関数はそのままメインスレッドで実行が開始されることになります。
この性質を鑑みると、メインスレッドで実行したくない sync 関数に関しては、 Task.init ではなく Task.detached を使うメリットがあることがわかります。

func someSuperHeavyComputation() {
  // sync 関数なので呼び出し元のスレッドで実行される
  // すごく重い計算を行うので、メインスレッドから呼び出されると
  // メインスレッドを占有して UI のレスポンスに問題が発生する
}

final class MyViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
    
    Task {
      await someAsyncFunction() // 中身がバックグラウンドスレッドで実行される
      someSuperHeavyComputation() // ❗️ 中身がメインスレッドで実行開始されてしまう
    }
    
    Task.detached {
      await someAsyncFunction() // 中身がバックグラウンドスレッドで実行される
      someSuperHeavyComputation() // ✅ 中身がバックグラウンドスレッドで実行開始される
    }
  }
}

しかし、このようなケースに関して、 someSuperHeavyComputation が存在すること自体が問題という考え方もできると思います。呼び出し側で毎回メインスレッドを占有しないように気を使って呼び出すことになるため、呼び出し側で気を使い忘れると問題になるという状況が常に発生しているためです。 someSuperHeavyComputation 自身が、どのように呼び出されても問題を起こさないように実装されているのが理想的です。

そのために最も簡単な方法は someSuperHeavyComputation を async 関数にしてしまうことです。 前述のように、 async 関数として実装されているという時点で、あえて main actor に isolate されていない限りはその関数はバックグラウンドスレッドで実行されることが確定するためです。また、もし要件が許すのであれば、オプションとして

  • 1つのスレッドを占有し続けないように適宜 Task.yield でスレッドを譲る
  • Task がキャンセルされて処理結果が必要なくなっても無駄に処理が走り続けないように定期的にキャンセルのチェックをする

などの対応ができれば、より問題を発生させづらい処理になると思います。

以下のように someSuperHeavyComputation を async 関数に変更すると、 Task.detached だけでなく Task.init から実行してもバックグラウンドスレッドで実行されるようになります。

func someSuperHeavyComputation() async {
  // async 関数なので常にバックグラウンドスレッドで実行される

  // some process

  // 任意: よきタイミングでスレッドを譲る
  await Task.yield()

  // some process

  // 任意: もし Task がキャンセルされていたら処理を中断する
  if Task.isCancelled {
    return
  }

  // ...
}

final class MyViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
    
    Task {
      await someAsyncFunction() // 中身がバックグラウンドスレッドで実行される
      await someSuperHeavyComputation() // ✅ 中身がバックグラウンドスレッドで実行開始される
    }
    
    Task.detached {
      await someAsyncFunction() // 中身がバックグラウンドスレッドで実行される
      await someSuperHeavyComputation() // ✅ 中身がバックグラウンドスレッドで実行開始される
    }
  }
}

これにより、 Task.detached を使う必要はなくなります。もちろんすべての場合で上記のような変更ができるわけではないですが、可能な場合はやる価値があるかどうか検討しても良いと思います。

なぜ Task.detached を使いたくないか

この記事では Task.detached は使わなくてもよければ使わない方がよいという前提で書かれていますが、最後にその理由についても触れておこうと思います。一例として、実際に Task.detached を使っていて直近で困った事例を紹介します。

記事の最初に述べたように、 Task.detached は task local values を受け継ぎません。最近 swift-dependencies という DI ライブラリを気に入ってよく使っているのですが、このライブラリは依存を柔軟に上書きする仕組みに task local values を利用しています。そのため、 Task.detached を使うとそこで task local values が途切れてしまい想定通りの依存注入が行えないということがありました。
この事例自体はニッチなもので、 swift-dependencies を使っていない限り同じ問題に当たることはないですし、またライブラリの動作のためにコードを書き換えるという行為も必ずしもよいとは限りません。しかし、基本的に task local values が受け継がれて困るということは考えづらいのに対して、受け継がれなくて困ることはあるので、 task local values を受け継ぐ Task.init を利用していた方がよい場面が多いと思います。同様の話が優先度に関しても言え、あえて優先度を受け継がない Task.detached を使う理由はないと思っています。

他に Task.detached を使っていて困る場面や、逆に Task.init よりも Task.detached を使う方が好ましい場面をご存知の方は教えていただけるとありがたいです。

Discussion