🌿

非同期処理とScalaのFuture

2022/02/23に公開

ScalaのFutureについて自身の理解を深めるためにまとめます。初歩的なことから学んだことをまとめていきますが、私自身は専門的に情報科学を学んでいるわけではないため、厳密な理解と異なる場合があるかと思いますのでご了承ください(気づいた方は指摘していただけると幸いです)。

プロセスとスレッドについて

Futureを理解するには非同期処理について知る必要がありそうです。さらに非同期処理(や非同期処理)について理解するには、プロセスやスレッドといった言葉について理解していることが必要そうです。

プロセスについて最初は漠然としかわからなかったのですが、具体的な例があるとわかりやすいかと思います。例えばchromeを立ち上げた時にはchromeのプロセスが立ち上がります。メモ帳をひらけばメモ帳のプロセスが立ち上がり、Excelを立ち上げればExcelのプロセスが立ち上がります。そしてそれぞれのプロセスはOSから異なるメモリ領域を割り当てられます(複数のプロセスがあるメモリ領域を互いに割り当てられることはないという認識です。お互いに割り当てられていたら作業の途中で書き換えられてしまうのでおそらく不都合が生じるでしょう)。こうして我々はchromeでWebサイトを閲覧しながら、メモ帳でメモをとったり、Excelで作業をすすめたりすることができるようです。Macだと 「command」+「option」+「esc」 で「アプリケーションの終了」という表示が出てくると思いますが、おそらくそこに表示された一つ一つのアプリケーションそれぞれにプロセスが立ち上がっていると認識すればよいかと思います。

スレッドとは「プログラムの実行単位」です。といってもこれだけではなんのことだと思うので、chromeでgoogleの検索フォームに文字を入力する時にキーを叩くと文字が入力されるその一つ一つの動作が一つのスレッドであると言えそうです。エクセルで行数が長いデータがあり、すべての行に対して一度に同一の処理(例えば各行の合計値を各行の一番右のセルに出力する)をした場合には計算が完了するまでに時間がかかると思いますが、それも一つのスレッドであると言えそうです。

プロセスとスレッドについて必ずしも1対1の関係である必要はないようであり、例えばchromeを立ち上げると一つのプロセスが立ち上がり、いくつもタブをひらいてそれぞれで作業をすれば複数のスレッドが存在するということは容易にそうぞうできそうです(具体的にはオンライン会議のためにgoogle meetを開いて会議に参加しつつ、別のウィンドウを横並びにして検索をすれば一つのプロセスの中で複数のスレッドが存在する(マルチスレッド)ことになります)。

参考にしたサイト
https://webpia.jp/thread_process/

同期処理・非同期処理について

同期処理・非同期処理について理解するとおそらく並行処理・並列処理という言葉もとに出てくると思います。まずはサクッと並行処理と並列処理についてまとめます。

並行と並列の違いについて考えてみると、同じ直線上に2つの線分が存在する時を想像すると意味合いが理解しやすいと個人的に思います。この場合、2つの線分は並行ではあるものの、並列ではない(横並びではない)というのを私は直感的に感じました。並行処理とはCPUの一つのコアが処理をしていると考えるのが筋がよさそうであり(厳密には違うっぽい?)、並列処理とはCPU複数のコアが完全に同時に複数のスレッドを処理していると考えるのが筋が良さそうです。ただし厳密な理解をしようとすると泥沼にはまっていきそうで、私自身が正しく理解していなさそうなので他に説明を譲りたいと思います(実際さまざまなサイトを読み漁りましたが、本質を理解できたとは到底思えないくらい分かりにくい概念だと感じます)。

ちなみに並行処理であたかも複数のスレッドを処理しているようにみえるのは、細かい時間で切り替えながらそれぞれの処理をしており、人間の感覚として複数の処理を同時にしているように見せているということらしいです。
参考にしたサイト
https://qiita.com/Kohei909Otsuka/items/26be74de803d195b37bd
https://zenn.dev/hsaki/books/golang-concurrency/viewer/term

続いて同期処理について理解していく必要がありそうです。同期処理のイメージとしてはベルトコンベア方式の工場の生産ラインかと思います。自動車の製造ラインではまずはプレス工程で車体の骨格を作り、溶接工程で車の形にし、次に塗装工程で色をつけて、、、と順番に行っていきます。これらには順番があり基本的には入れ替えることはできないため同期処理といえるのではないでしょうか。プレス工程がおわったら溶接工程、溶接工程がおわったら塗装工程、、、と進んで行き、先取りすることはできないというわけです。

一方非同期処理は料理みたいなものであると理解すると良さそうです。例えばカレーライスを作るとすると、ご飯を炊くのと同時にカレーを作ると思います(ご飯を炊き終わってからカレーを作り始めることをする人はあまりいないのではないでしょうか)。カレーライスを作るという行為は「ご飯を炊く」と「カレーをつくる」の非同期処理と言えるでしょう。

ScalaのFutureについて

Futureについてはさまざまなところにその説明が書かれていますが、非同期処理にて計算が完了すると結果が入るオブジェクトであるという理解です。といってもやはり分かりにくいとは思うので、ここは実際にコードを書いて同期処理と比較して理解するのが良さそうであると思います。

以下試しに書いて見たコードです。// numと書かれているのは実行後にprintlnで出力された順番です。

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

object Main extends App{
  val f: Future[String] = Future {
    println("1st: Hello")  // 1
    Thread.sleep(1000)
    println("1st: world") // 7
    "1st: Result"
  }

  val s: Future[String] = Future {
    println("2nd: Hello") // 2
    println("2nd: world") // 3
    "2nd: Result"
  }

  f.map(g => println(g)) // 8
  s.map(t => println(t)) // 6

  println("1st: " + f.isCompleted.toString + " before sleep") // 4
  println("2nd: " + s.isCompleted.toString + " before sleep") // 5

  Thread.sleep(2000)

  println("1st: " + f.isCompleted.toString + " after sleep") // 9
  println("2nd: " + s.isCompleted.toString + " after sleep") // 10
}

実行結果は以下の通りとなりました。何度か実行すると順番が入れ替わる可能性があります。

1st: Hello                 // 1
2nd: Hello                 // 2
2nd: world                 // 3
1st: false before sleep    // 4
2nd: true before sleep     // 5
2nd: Result                // 6
1st: world                 // 7
1st: Result                // 8
1st: true after sleep      // 9
2nd: true after sleep      // 10

4番目の出力はfの評価が完了していないのでfalseですが、5番目に出力された時にはsの評価は完了しておりすでに結果が入っているのでtrueというのがわかると思います。一方で実行順と出力順が5番目と6番目で入れ替わっているというのも面白い結果だと思います。

上記では意図的にFutureに結果が入るのを待つために2000msecを待っていましたが、それを待たないとどうなるか試して見ます。先ほどのコードとほぼ同じですが一応実行したコードは以下の通りです。

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

object Main extends App{
  val f: Future[String] = Future {
    println("1st: Hello") // 1
    Thread.sleep(1000)
    println("1st: world") // 出力されない
    "1st: Result"
  }

  val s: Future[String] = Future {
    println("2nd: Hello") // 2
    println("2nd: world") // 3
    "2nd: Result"
  }

  f.map(g => println(g)) // 出力されない
  s.map(t => println(t)) // 8

  println("1st: " + f.isCompleted.toString + " before sleep") // 4
  println("2nd: " + s.isCompleted.toString + " before sleep") // 5

  // Thread.sleep(2000)

  println("1st: " + f.isCompleted.toString + " after sleep") // 6
  println("2nd: " + s.isCompleted.toString + " after sleep") // 7
}

結果は以下の通りとなります。こちらも何度か実行すると順番が入れ替わることがあります。

1st: Hello                // 1
2nd: Hello                // 2
2nd: world                // 3
1st: false before sleep   // 4
2nd: false before sleep   // 5
1st: false after sleep    // 6
2nd: false after sleep    // 7
2nd: Result               // 8

というわけでfの評価中の途中でMainのスレッドが完了して抜け出してしまったという結果と理解できます。

もし上記で出力されなかった2つを出力したい場合にはAwaitを使うことで実現可能であります。Await.readyの場合にはFutureで包まれた形で値が返ってくる一方、Await.resultの場合にはFutureの中身が帰ってきます。

import scala.concurrent.{Await, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._

object Main extends App{
  val f: Future[String] = Future {
    println("1st: Hello")
    Thread.sleep(1000)
    println("1st: world")
    "1st: Result"
  }

  val s: Future[String] = Future {
    println("2nd: Hello")
    println("2nd: world")
    "2nd: Result"
  }

  f.map(g => println(g))
  s.map(t => println(t))

  println("1st: " + f.isCompleted.toString + " before sleep")
  println("2nd: " + s.isCompleted.toString + " before sleep")

  // Thread.sleep(2000)
  val fAwaitReady = Await.ready(f, 2000.millisecond)
  val sAwaitResult = Await.result(s, 2000.millisecond)
  println(fAwaitReady)
  println(sAwaitResult)

  println("1st: " + f.isCompleted.toString + " after sleep")
  println("2nd: " + s.isCompleted.toString + " after sleep")
}
1st: Hello
2nd: Hello
2nd: world
1st: false before sleep
2nd: false before sleep
2nd: Result
1st: world
1st: Result
Future(Success(1st: Result))
2nd: Result
1st: true after sleep
2nd: true after sleep

しかし、Awaitを使うことのデメリットもあるため、実際に使う場合にはメリットとデメリットを比較する必要がありそうです(以下の参考資料によるとDBからデータを取ってくる話に限定されるのかは不明)。Futureで受け渡し可能な場合はわざわざAwaitを使わなくても良いと思いますし、mapforeachFutureの中身を使うことはできるっぽいので、それらでうまい処理方法を考えた方が良さそうです。
https://qiita.com/mikene_koko/items/c956677693fbd2e86da6

結局は手を動かしてみるのが良い

以上実際に自分で手を動かして何が起きているのか理解して見た結果となります。結局Futureついてマスターしたのか?と問われるとYesと答えられないのですが、こうやって今回さまざま手を動かしてやってみるのは良い勉強になりました。

また、おそらく実際にコードを書いて確かめて見たいという人がいると思いますが、その場合には競プロ用に作ってまとめていたリンクを参考にDockerfileだけ差し替えてコンテナを立てて実行してみるのが良いと思います。
https://zenn.dev/at12/articles/b2be2e2c643a12

Dockerfile
FROM ubuntu:20.04
RUN apt-get update && apt-get install -y \
    scala
WORKDIR /work

コンパイル&実行は以下

scalac main.scala   // コンパイル
scala Main          // 実行

Discussion