LoginSignup
6
7

More than 3 years have passed since last update.

【並列・並行解説付き】goroutineでマルチスレッド処理を試してみた

Last updated at Posted at 2021-03-25

はじめに

タイトルをあえて、"マルチスレッド"とだけ書き、並行・並列という言葉を使うのを避けました。
理由としては、

  • goroutineruntime.NumCPU()の値(論理コア数)が複数だった場合、並列処理となる。
  • 論理コアごとにgorotineと紐づくカーネルスレッドが実行されるM:Nモデルを採用しているので、同時に複数の処理が走る。(並行処理)

ただ、並行は並列を包含するので、
強いて言えば、goroutineは、並行処理というのがいいのかもしれませんね。

すでに「並」でゲシュタルト崩壊発生( ;´Д`)

マルチスレッドとは

すでに、並列処理・並行処理ってなんやねん!!って状態だと思いますが、
図をご覧下さい。
スクリーンショット 2021-03-25 11.48.52.png

並行(concurrent)

並列とは違い、複数のタスクを1人が処理するイメージ。
シングルコアのCPUが複数の処理を同時に動かすことなどが挙げられる。

たとえば、主婦が、炊事、洗濯、掃除を切り替えながら同時かつ並行的に家事を進める感じ。

並列(parallel)

並行とは違い、複数人が同時に複数のタスクを処理すること。
複数のコアのCPUが、それぞれのスレッドを同時に動かすのであれば、それは並列処理
「並行かつ並列」という状態は起こりうる

たとえば、主婦が家政婦を引き連れて陣形を組み、
主婦:炊事、家政婦A:洗濯、家政婦B:掃除
みたいに担当を決めて、同時かつ並列的に家事を進める感じ。
ただ、主婦が買出しも引き受けて、炊事と並行的に二つを進めれば、並行かつ並列の状態。

goroutineの特長

ざっくり言うと、マルチスレッド処理が簡単に実装できてかつ、高速に実行できるすごいやーつ。

理由としては、

  • 複数のユーザ・スレッドに対して、複数のカーネル・スレッドをマップするM:Nモデルを使用してるので、ユーザスレッドであるgoroutineはコンテクストスイッチするが、カーネルスレッドはコンテクストスイッチせずに、オーバーヘッドが発生しづらいから。
  • goroutineのスタックはガードページを使わない&初期サイズが2KBだから、メモリ使用量が少ない

用語

コンテキストスイッチとは、コンピュータの処理装置(CPU)が現在実行している処理の流れ(プロセス、スレッド)を一時停止し、別のものに切り替えて実行を再開すること。
IT用語辞典 e-Words

 

ガードページは、ヒープブロック間にガードページと呼ばれるアクセスを禁止した領域を置くことで、ヒープ領域を超えて書き込みが行われた場合、プログラムを異常終了させるものです。
ヒープに対する攻撃とその対策

goroutineの構成要素

  • goroutine:マルチスレッド化
  • channel:スレッド同士で値のやりとりをできる経路の役割
  • select:複数のチャネル操作を待つことができる

という3つの機能を駆使するといいらしい。

※本編では goroutine のみで実験します。channelとか別で挑戦したい。

書き方

goroutine
// function定義をしてgoroutine実行
func goroutine(){
    //処理
}
func main() {
    go goroutine()
}
goroutine
// 無名関数を定義して

func main() {
    goroutine := func() {
        //処理
    }
}
goroutine
// 無名関数的に(省略形)
func main() {
    go func() {
        //処理
    }() //←()忘れないように
}

// ちなみにループだと、コピーを引数に渡さないと、参照になってしまい、想定しない数字が出力されるよ
for _, val := range values {
    go func(i int) { // 引数を追加
        fmt.Println(i)
    }(val) // 関数実行時に現在の値を渡す
}

コード

今回は、go gin のフレームワークを使って、REST APIを作成し、複数のURLを[,]区切りで入力すると、そのページ先からタイトルを抽出して、かかった時間と一緒に結果を返すというmain.goにしてます。
Webスクレイピングには、goquery という便利なライブラリを使いました。
(※エディタはgolandを使いました。Go Modulesとかの管理が楽)

main.go

main.go
package main

import (
    "bytes"
    "fmt"
    "golang.org/x/net/html/charset"
    "io/ioutil"
    "log"
    "net/http"
    "strconv"
    "strings"
    "sync"
    "time"

    "github.com/PuerkitoBio/goquery"
    "github.com/gin-gonic/gin"
    "github.com/saintfish/chardet"
)

func main() {
    // Start HTTP server
    r := gin.Default()
    r.GET("/scrape", scrapeText)
    if err := r.Run(":8080"); err != nil {
        log.Fatal(err)
    }
}

// ページタイトルと取得時間の構造体
type Title struct {
    Title string
    Time string
}

func scrapeText(c *gin.Context) {
    //クエリストリングから値を受け取り配列化
    var urls []string
    urls = strings.Split(c.Query("urls"), ",")

    //syncでURLの数だけ待ち合わせするWaitGroupの宣言
    var wg sync.WaitGroup
    wg.Add(len(urls))

    //titlesを格納する変数宣言
    var results []Title

    //titleをfetchする無名関数を代入
    fetchTitle := func(url string) {
        start := time.Now()
        defer wg.Done()

        // Getリクエスト
        res, _ := http.Get(url)
        defer res.Body.Close()

        // 読み取り
        buffer, _ := ioutil.ReadAll(res.Body)

        // 文字コード判定
        detector := chardet.NewTextDetector()
        detectResult, _ := detector.DetectBest(buffer)
        fmt.Println(detectResult.Charset)

        // 文字コード変換
        bufferReader := bytes.NewReader(buffer)
        reader, _ := charset.NewReaderLabel(detectResult.Charset, bufferReader)

        // HTMLパース
        document, _ := goquery.NewDocumentFromReader(reader)

        // titleを抜き出し
        title := document.Find("title").Text()
        end := time.Now();
        time := (end.Sub(start)).Seconds()
        result := Title{title, strconv.FormatFloat(time, 'f', -1, 64) }
        results= append(results, result)
    }

    //urlsの回数分スレッド実行
    for _, url := range urls {
        go fetchTitle(url)
    }

    //fetchTitle goroutineが終わるまで、wg.Wait()で待つ
    wg.Wait()

    c.JSON(http.StatusOK, results)
}

index.html

Fetch APIをつかってリクエストを送ってみました。
'Content-Type': 'application/json'なので、プリフライトリクエストが送られる模様。
参考元:CORS

index.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta http-equiv="content-type" charset="utf-8">
  <title>Test Application</title>
</head>

<body>

  <form name="multiScraping" id="multiScraping">
    <p>URL:[,]カンマ区切りで複数入力</p>
    <p><textarea name="urls" rows="10" cols="80" placeholder="URL記入"></textarea></p>
    <p><input type="button" value="scrape" onclick='getTitles()'></p>
  </form>

  <ul id="titles"></ul>

  <script type="text/javascript">
    function getTitles() {
      const urls = document.forms['multiScraping'].elements['urls'].value;
      const params = { // 渡したいパラメータをJSON形式で書く
        urls: urls,
      };
      const queryParams = new URLSearchParams(params);
      const url_string = 'http://localhost:8080/scrape?';
      fetch(url_string + queryParams,
        {
          method: 'GET',
          headers: {
            'Content-Type': 'application/json',
          }
        })
        .then((res) => {
          if (!res.ok) {
            throw new Error(`${res.status} ${res.statusText}`);
          }
          return res.json();//.json()にするとパースされたJSONが取得できる(promiseが返される)
        })
        .then((data) => {
          console.log(data);
          // 詰め替え処理して表示させる
          for(let i=0, len=data.length;i<len;i++) {
            let title = document.createElement("li");
            title.setAttribute("id", "title-"+i);
            title.innerHTML = data[i]['Title'] + '' + data[i]['Time'] + '';
            document.getElementById("titles").appendChild(title);
          }
        })
        .catch((reason) => {
          console.log(reason);
        });
    }
  </script>
</body>

</html>

早速試してみる

まずは、URLをカンマ区切りで複数入力してみる。

とりあえず主要どころのGoogle,Yahoo,Facebook,Amazon、もちろんQiitaさんもいれてみました。
スクリーンショット 2021-03-25 12.47.07.png

scrapeボタンをポチッとなと...

スクリーンショット 2021-03-25 12.49.20.png

おおお、タイトルと掛かった時間が取得できた。

URLの順番的には、

  1. Google
  2. Yahoo
  3. Qiita
  4. Facebook
  5. Amazon

と入力したけど、

返ってきた結果は、

  1. Yahoo
  2. Google
  3. Qiita
  4. Facebook
  5. Amazon

の順番でした。
スレッドだから順不同なので、早く終わったものから、
レスポンス用の配列に追加されてった感じですな。

2回目やると

スクリーンショット 2021-03-25 13.00.33.png

  1. Yahoo
  2. Google
  3. Facebook
  4. Qiita
  5. Amazon

順番が変わった。

まとめ

goroutine奥が深すぎて、難しい印象。だけど記述自体はものすごく簡単だ。

マルチスレッドプログラミングができるようになるには、
メモリのこととか、処理の待ちとか、値の受け渡し方、チャンネルのこととか色々考えていかないといけないんだな〜
って思いました。
まだまだ習得には時間がかかりそうです。

頑張ってまいります!

以上、ありがとうございました!

6
7
0

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
6
7