LoginSignup
18
11

More than 3 years have passed since last update.

フルスタックの視点から並列非同期処理を俯瞰してみた(JavaScript(Node.js), Kotlin, Go, Java)

Last updated at Posted at 2019-12-13

はじめに

この投稿はCyberAgent Developers Advent Calendar 2019 14日目の記事ですー

私はフルスタックエンジニアですが、フルスタックの視点から俯瞰してみた時に、プログラミングがどういう見え方をしてるか?というのを伝えられたらと思っています。

いくつか言語を学んでる方は、異なる言語を学ぶ時に、あの言語だとこうできたのに、、、こっちの言語だとこれ便利だな。とか感じたことがあるかと思います。異なる言語でのテクニック・手法・書き方などを並べて解説している記事があまりないと思ったので、あえて異なる言語での同じ手法の例を、1つの記事として見比べてみようと思います。

今回は同時に非同期処理を実行したい場合の例を参考にお伝えできればと思いますが、この記事をでは「並列・並行処理の違い」や、「マルチスレッドとイベントループの違い」など、小難しいことは省きます。
複数言語を記載してるので長文になりますが、読んでもらえたら幸いです。

今回は昔からの定番や、最近私もよく使う下記の言語での例を書いています。

  • JavaScript(Node.jsでの例です)
  • Kotlin(Androidでの例です)
  • Go
  • Java

非同期で同時に処理をなげる

近年マイクロサービスが流行っているかと思いますが、マイクロサービスになると、フロント側に近くづくほど、「あっちのデータはこのAPIから」「こっちのデータはこのAPIから。。。」と、たたくAPI多すぎて、それの結果をまとめて、画面用に加工して。。。ってやるケースは結構増えてると思います。BFF(Backend for frontend)のAPIでやってたりする場合もありますが、クライアント側(JSやスマホアプリ側)でやるケースも少なからずあると思います。
どのケースでもそうですが、叩くAPIが多いほどレスポンス速度や画面の表示速度に影響するので、順番に1つずつAPIを実行するのではなく、APIを同時に処理させた後、結果を加工したいっていうケースも増えてきてると思います。

例えば、3秒かかるAPI、2秒かかるAPI、1秒かかるAPIと3つのAPIを実行する必要があり、それを画面用に加工する処理が1秒あって、結果をくっつけたら画面に表示できる。というケースの場合。
前提としてクラスの呼出などのオーバーヘッドは除く

下記の図のように順番にAPIを実行すると、画面を表示するまでに7秒かかりますが、

順番にAPIを実行した場合、合計7秒かかる図

同時にAPIを実行すると、画面を表示するまでに4秒に短縮できます。

同時にAPIを実行した場合、合計4秒かかる図

このように同時に投げる手法は必要になるケースは増えてきてると思いますが、言語によって結構書き方が違うと思います。
基本は上の図のようなフローは変わらないので、

  • 考え方
  • フローを思い描く
  • 基本的なプログラムの動き(プロセスやスレッドなど)

を身につけてしまえば、言語が変わってもちょっと調べれば、大体想像がつくようになります。もちろん言語によって特性が違うので、そこは使う時に深堀しないと思わぬ不具合に繋がる可能性もありますが、実際に利用してみないとよくわからないとおもうので、ここでは深く書きません。

それでは、実際に同時に処理を投げるプログラムを異なる言語で見比べてみます。

JavaScript(Node.js)の非同期処理

JavaScript(Node.js)では、Promiseを使います。下記ソースみてもらうと、PromiseってPromise.allしかない?と思いますが、メソッドにasyncをつけるとreturnが全てPromiseになります。その、promiseを同時に実行してね、に近いことをしているのが、Promise.allです。
Promise.allでawaitすると、allの配列に指定した関数の結果が全て返却されるまで、Promise.allの行で待機してくれます。test1とtest2は、ほぼ同時に実行されるので、順番に実行するよりも早くレスポンスを返すことが可能です。
なお、JavaScriptですが、Node.jsを前提としています。ブラウザ上のJSだと、chromeとか最近のブラウザだとPromise使えますが、IE11とか古いブラウザ(対応打ち切ってるところ増えてきてますが)だとPromise使えないものもあるので、webpackとかでpolyfillを入れて、古いブラウザでも動くように一度変換が必要です。

JavaScript(Node.js)のソース

const main = async () => {
    console.log('[JavaScript] main start')
    // 処理を非同期で複数投げ、Promise.allで全ての結果が帰ってくるまで待つ
    const result = await Promise.all([
        test1(),
        test2(),
    ])
    console.log(result[0])
    console.log(result[1])
    console.log('[JavaScript] main end')
}

const test1 = async () => {
    console.log("test1 method")
    await sleep(2000) // APIとかを呼び出していると仮定
    return 123
}

const test2 = async () => {
    console.log("test2 method")
    await sleep(1000) // APIとかを呼び出していると仮定
    return 456
}

// JavaScriptは便利なThread.sleep的なものがないので近いものを再現します
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// main関数を実行
main()

実行結果

$ npm run main

[JavaScript] main start
test1 method
test2 method
123
456
[JavaScript] main end

補足 Promiseとasync/awaitの関係

Promiseは非同期処理を実行するためのものですが、Node.js V8以前(ベータ版の時期除く)はasync/awaitがなく、非同期処理をする場合、new Promise() を使い、非同期の結果をコールバックチェーンさせてくという、コールバック地獄でした。コールバック地獄はソースの可読性が落ちるので、async/awaitが出来き、可読性が上がり、Promiseとあまり書かなくてよくなりました。TypeScriptを使ってreturnの値を見るとasyncのついた関数の戻り値は、かならずPromiseがつくことがわかるとおもいます。

// TypeScript
const testFunc = (): Promise<number> => {
   return 123
}

Promise.allは同時実行で使うので残ってますが、基本はPromiseはあんまり使わなくて大丈夫ですが、ちょっと古いライブラリは結果をcallbackで返してくるものがまだあります。そんな時にPromiseで一度包んであげると、async/awaitにできるで、補足として記載します。

サンプルソース

/**
 * callback関数になっているライブラリをasync/awaitにしたい場合の例
 */
const main = async () => {
    console.log('[JavaScript] main start')
    const result = await testFunc()
    console.log(result)
    console.log('[JavaScript] main end')
}

// promiseでcallbackをasync/awaitで実行できるように、callbackで帰ってくるAPIを包んでます
const testFunc = () => {
    return new Promise(resolve => {
        console.log("testFunc method")
        callbackMethod(ret => {
            resolve(ret)
        })
    })
}

// callbackになっているライブラリの変わり
const callbackMethod = callback => {
    callback(456)
}

main()

Kotlinの非同期処理

Kotlinは Coroutine を使います。Coroutineは新しいkotlinのversionだと使えますが、ちょっと古いと使えなかったりします。その場合はJava時代からあるThreadとかを使うことになってしまうので、Coroutineが使える新しいKotlinを前提に説明します。
Coroutineは、単純にいうとJavaのThreadを使いやすくした物です。Coroutineを実行した際にThread番号をdebugで見ると異なるThreadで実行されてることがわかると思います。
基本的には、async/awaitの考えかたはJSと一緒で、Coroutineを実行しているメソッドはsuspendをつけ、予備だし側にawaitするか?しないか?判断を委ねたり、runBlockingなどを使って、呼び出したところで同期処理に戻してる感じにします。この例だと、test1とtest2を同時に実行したかったので、doAllのなかで、二つの関数をasyncで呼出し、awaitで2つの結果が終わるのを待ってから、呼出もとに返してます。JSみたいなPromise.allはないので、結果をarrayに筒んであげれば、Promise.all風のメソッドが再現できます。

Kotlinのソース

package sample.kotlin

import kotlinx.coroutines.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

fun main() {
    println("[kotlin] main start")

    // doAllはsuspendなので、runBlockingをして中の処理がすべて完了するまで待つ
    runBlocking {
        val result = doAll()
        println(result[0])
        println(result[1])
    }

    println("[kotlin] main end")
}

/**
 * JavaScriptPromise.all処理結果と似た様な実装
 * JavaでいうとExecutorService Callable
 */
suspend fun doAll(): Array<Int> = coroutineScope {
    val ret1 = async { test1() }
    val ret2 = async { test2() }

    // Promise.all風
    // メソッドの実行結果をarrayにいれて返してみると、promise.allっぽくなる
    arrayOf(ret1.await(), ret2.await())
}

suspend fun test1(): Int {
    println("test1 method")
    delay(2000) // APIとかを呼び出していると仮定
    return 123
}

suspend fun test2(): Int {
    println("test2 method")
    delay(1000) // APIとかを呼び出していると仮定
    return 456
}

実行結果

Android StudioでMain.ktを右クリックしてRunを実行。

(kotlincとか入れてコマンドラインからできるようにしようと思ったんですが、coroutineつかってるし設定に時間がなく、時間短縮のためAndroid Studioからやりましたm(_ _)m)

[kotlin] main start
test1 method
test2 method
123
456
[kotlin] main end

補足 Coroutineでもcallback系のAPIをasync/awaitに変換してみる

CoroutineはJavaのThreadよりに楽になったとは言え、機能が多くどれ使ったらいいの?ってなると思います。
今回は1例として、suspendCoroutineを使うと、JSの new Promise()つかってasync/awaitしたのと同じように、コールバックをやめられる例を記載します。

cont.resume(it)
これが、jsの
resolve(ret)
と同じ感じで、resume実行するとreturnされます。

こうすると、この行のように、callbackがasyncにできます。

val func = async { testFunc() }
val result = func.await()

違う言語で、書き方違ってもこうやってやりたいことは出来たりします。

サンプルソース

package sample.kotlin

import kotlinx.coroutines.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

fun main() {
    println("[kotlin] main start")

    // JavaScriptでいうと、awaitっぽい感じ。なの通りでrunBlocking内の処理が終わるまで待ってくれる
    runBlocking {
        val func = async { testFunc() }
        val result = func.await()
        println(result)
    }
    println("[kotlin] main end")
}

/**
 * JavaScriptでいうところのnew Promise()と同じ様な効果。
 * suspendCoroutineでcallbackをasync/awaitで実行できるように、callbackで帰ってくるAPIを包んでます
 */
suspend fun testFunc(): Int {
    println("testFunc method")
    return suspendCoroutine { cont ->
        callbackMethod {
            cont.resume(it)
        }
    }
}

// callbackになっているライブラリの変わり
fun callbackMethod(ret: (Int) -> Unit) {
    ret(456)
}

Goの非同期処理

goはgoroutineとchanを使います。goroutineは、go というキーワードで関数を実行すると非同期になります。go だけだと結果をうけとれないので、その非同期の結果を受け取るために chan (channel)を使います。chanは awaitと同じような効果があり <- で awaitしてくれます。それ以外は今までと同じようなフローで動きます。ただし、中身の動きはgoは並行処理というの採用しています。ここではchannelについてはそこまで詳しく書きませんが、goの場合waitGroupというのもあります。詳しく知りたい方は 並列処理と並行処理 などで検索してみてください。

今回chanにしたの、今まで紹介してきたJSのPromise.allなどのように、1つ目、2つ目と結果を確実に格納したかったからです。ここでポイントなのが、channelを2つmakeし、第二引数のバッファを1だけ確保している点です。
下記のように1つのchannelで、バッファを2にして、

channel := make(chan int, 2)

go Test1(chanel)
go Test2(chanel)

resultArray = append(resultArray, <- chanel)
resultArray = append(resultArray, <- chanel)

のように、書くこともできるのですが、確実に最初の配列にTest1の結果が返ってくる保証がないからです。実際実行すると、処理のタイミングによって配列0にTest2の結果が入ったりします。channelを分けることで確実にTest1の結果はchannel1に入れることができるので、今回のサンプルでは分けました。それを前提に以下のソースをみてもらうと良いかと思います。

Goのソース

package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Println("[go] main start")

    result := doAll()
    fmt.Println(result[0])
    fmt.Println(result[1])

    fmt.Println("[go] main end")
}

func doAll() []int {
    resultArray := make([]int, 0, 2)
    chanel1 := make(chan int, 1)
    chanel2 := make(chan int, 1)

    // goroutineで非同期処理
    go Test1(chanel1)
    go Test2(chanel2)

    // Promise.all風実行結果を配列に詰め込んでいます。
    // <- は、jsやkotlinでいうawaitキーワードみたいなものです。Javaだとfuture.get()
    // 全ての実行結果が買ってくるまで、awaitしてる感じです。
    resultArray = append(resultArray, <- chanel1)
    resultArray = append(resultArray, <- chanel2)

    close(chanel1)
    close(chanel2)
    return resultArray
}

func Test1(c chan int) {
    fmt.Println("test1 method")
    time.Sleep(time.Second * 2) // APIとかを呼び出していると仮定
    c <- 123
}

func Test2(c chan int) {
    fmt.Println("test2 method")
    time.Sleep(time.Second * 1) // APIとかを呼び出していると仮定
    c <- 456
}

実行結果

$ go run src/main.go

[go] main start
test2 method
test1 method
123
456
[go] main end

goroutineは実行順序を保証してくれないので、先にtest2が実行されていますが、awaitして待ってるので、呼び出し側では気にする必要はないです。

Javaの非同期処理

Javaは、古くからある言語のため、非同期処理のやり方がいくつかありますが、今回はJava7くらいから追加された、Executorを使います。いろいろなやり方はありますが、基本的にやってることは同じでJavaのスレッド処理です。今回は非同期の実行結果を受け取りたかったので、Callable クラスを使って実装しています。submitした時に非同期処理が実行され、
future.get() としてる箇所がawaitで、実行結果を待ちます。その結果をListに入れることで、Promise.allのような実行結果を再現しています。Javaの場合Classを作ったりFutureを使ったりと、非同期をするための手続きが他に比べて多いです。Executorを使ったのは、KotlinはJVMで動くので結果中身はJavaなんですが、この記事を執筆している時点の、AndroidのCameraXライブラリを使う場合、Executorのインスタンスを渡す必要があったので、Executorを使ってサンプルを作ろうと思いました。この辺りです。
Google CodelabsGetting Started with CameraX

Javaは昔からある言語で手数は多いですが、KotlinになってもJavaのライブラリは良く呼びますし、そもそもThreadとは?っていうこをを抑えるのには、まだまだ役立つと思っております。

Javaのソース

package sample;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

class JavaApplication {

    public static void main(String[] args) {
        System.out.println("[Java] main start");

        List<Integer> result = doAll();
        System.out.println(result.get(0));
        System.out.println(result.get(1));

        System.out.println("[Java] main end");
    }

    /**
     * JavaScriptPromise.all処理結果と似た様な実装
     * KotlinのcoroutineScopeでasyncした結果と似た様な実装
     */
    private static List<Integer> doAll() {
        List<Future<Integer>> futures = new ArrayList<>();
        List<Integer> result = new ArrayList<>();
        ExecutorService executor = Executors.newFixedThreadPool(2);
        try {
            // この2行がjsやkotlinでいうasyncキーワードとかPromiseと大体動きは同じ思ってください
            futures.add(executor.submit(new Text1Class()));
            futures.add(executor.submit(new Text2Class()));

            // Promise.all風
            // Callableを実装したクラスの実行結果をArrayListにいれて返してみると、promise.allっぽくなる
            for (Future<Integer> future : futures) {
                result.add(future.get()); // future.get() が jsやkotlinでいうawaitキーワードみたいなものです
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            executor.shutdownNow(); // Javaは明示的にスレッド止めないと、プロセスが落ちてくれません。
        }
        // このループで全ての実行結果が買ってくるまで、awaitしてる感じです。
        return result;
    }
}

class Text1Class implements Callable<Integer> {

    @Override
    public Integer call() throws InterruptedException {
        System.out.println("test1 method");
        Thread.sleep(2000); // APIとかを呼び出していると仮定
        return 123;
    }
}

class Text2Class implements Callable<Integer> {

    @Override
    public Integer call() throws InterruptedException {
        System.out.println("test2 method"); // APIとかを呼び出していると仮定
        Thread.sleep(1000);
        return 456;
    }
}

実行結果

$ cd src
$ javac sample/Main.java
$ java sample.JavaApplication

[Java] main start
test1 method
test2 method
123
456
[Java] main end

まとめ

今回は4つの言語を使って、それぞれの同時に非同期処理を実行する一例をあげさせていただきました。
JavaScriptの実行に近い形で他の言語のコードも合わせましたが、基本的な考え方はスレッドでの非同期実行や、waitでスレッドの実行完了を待つsignalを受け取る考え方が基本です。JavaScriptは、シングルスレッドのイベントループでスレッドではなく他の言語はスレッドになりますが、 async/awaitの考え方 や 処理フロー は言語が異なっても応用できるので、こう言った 考え方 を身につけておくことが技術力の向上に繋がると思っています。
今回の非同期はあくまで一例です。言語を覚えると言うより、ある言語を使いアーキテクチャ、手法、ライフサイクル、フローなどの基礎を理解することが重要です。

  • 非同期処理であれば、プロセスや、スレッドおよびスレッドセーフなプログラム
  • ライフサイクルであれば、それぞれのフレームワークのライフサイクルの特徴や動き
  • その言語の得意なこと、苦手なこと
  • ネットワークプロトコルの理解
  • 画面へのデータの流れ方、etc...

上にあげたのも一例ですが、プログラムを通して、そういった基本的なことを理解することが重要です。

最後に

フルスタックになるにはどうしたらいいですか?と時々聞かれますが、真面目に 基礎 が大事と言えます。
ただ1点広く深く(応用)覚えようとすると、もちろんそれぞれの言語やアーキテクチャの細かい違いなどがあり、それなりに時間をつかいます。一昔前はエンジニアは細かく領域がわかれていませんでしたが、近年フロント、ネイティブ、バックエンド、インフラエンジニアなどと分かれているのは、それぞれの分野がより高度な知識や経験が必要になり、専属でないとなかなかキャッチアップが大変になってきたからだと思います。

そんな中でも、私は広く深くフルスタックで行こうと思います。もちろん深くいくので、人の2倍3倍は働いていますし勉強もしています。その努力した分は裏切らないし、全分野で深く知っているからこそ提案できる、全体アーキテクチャであったり、ライブラリであったりできることは格段に広がります。そういう開発ができて私はすごく楽しいと思っています。

プログラムを始めたばかりの方は、とりあえず1つの言語で、上にあげたようなプログラムの基礎をしっかりと理解し、自由に使いこなせるようになってから次の言語を学ぶと、おのずと理解しやすくなると思います。また、コンピュータやOSなど、コンピュータサイエンス的なところも後から、「知っててよかった」っていうところは出てくるので、勉強のしかたがわからない方は、基本情報処理技術者試験や応用処理技術者試験などの参考書などをみてもいいかもです。資格は持ってますが、資格自体はあまり役にたった記憶はありません^^;ただ、その過程で勉強した基本的な仕組みは今でも役立っています。

プログラム言語やフレームワークなどは流行りなどもあるので、今まで覚えた物が使えなくなると思うかたもいるかもしれません。ただ、言語やフレームワークは変わっても、基礎は変わらないので、新しいものが登場した時でも、今まで学んできた 考え方 はどこかで必ず役立ちます。

私の例でいうと、本当つい最近ですが、AndroidのKotlinでChannel(JavaでいうBlockingQueue)やReentrantLock、TCP/UDP通信などを作って低レイヤーからスレッドセーフな独自ライブラリを作ったんですが、10年前くらいに参画したプロジェクトでスレッドセーフなプログラムや通信周りのプログラムを使ったフレームワークの開発に携わっていたことがあるんです。当時学んだ考え方や設計力が今役立ったなぁと思った今日この頃でした。

どの時代でも技術は流行り廃りはありますが、

努力して学んできた考え方は裏切りません

最後にこの一言で終わりにさせていただきます。

今回作ったサンプルプロジェクトはこちらです。
https://github.com/tanaka-yui/parallel-async-sample

長文にお付き合いいただきありがとうございました。

18
11
2

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
18
11