Goでのマルチスレッドプログラミング

問題が発生しました:独自のプログラミング言語のコンパイラがあり、それを使用して基本的な方言をCのソースコードにコンパイルします。



残念ながら、歴史的な理由により、このコンパイラの明確な回帰テストはありませんでした。 しかし、今、この基本で書かれたビジネスアプリケーションのソースコードに基づいて、本格的なテストを行うことにしました。



計画は次のとおりです。現在のバージョンのコンパイラを参照してください。クライアントからの苦情は未解決ですが、参考にしてください。 適切な量​​のソースでこのバージョンをコンパイルし、結果を保存してから、コンパイラに変更を加えるたびに、これらすべてのソースを実行して、まったく同じ出力が生成されるかどうかを確認します。 これは一般にエラーを防ぐことはできませんが、少なくとも既存のビジネスコードがまだ正しくコンパイルされているという確信はあります。



簡単なタスク。 「but」は1つだけです。 参照ソースとして使用する予定のソースの数は約15,000ファイルで、合計ボリュームはギグよりわずかに少ない(便宜上、これらは1つのTARにラップされています)。 このような「実行」は非常に長くなる可能性があります。 また、タスクは完全に並列化されているため、マルチプロセッサマシンを使用してテストをできる限り高速にしたいという自然な要望があります。



あるいは、Makefileを作成し、GNU Makeで-jスイッチを使用して実行することもできます。 ただし、特殊なマルチスレッドプログラムを作成すると、パフォーマンスが向上します。





したがって、明らかです。シーケンシャル実行の代わりに、並列スレッドで各ファイルのコンパイルを開始する必要があります。 しかし、多くのファイル(〜15,000)があるため、一度にできるだけ多くのスレッドを開始するだけでは効率的ではありません。 スレッドのプールを用意するのが最も合理的です。スレッドの数は、たとえばプロセッサの数(たとえば、2倍)によって決定されます。 プールは別のタスクを空きスレッドに割り当て、すべてのスレッドがビジーの場合、空きスレッドが現れるまでブロックします。



したがって、不必要なコンテキストの切り替えやスレッドの絶え間ない作成と破棄に時間を浪費することなく、N個のスレッドをビジーにして、最適なプロセッサー使用率を確保します。



最初はすべてをC ++とpthreadで書くことにしました。 ファンクター、ミューテックス、セマフォ、条件変数を数時間踊り続けた後、何もうまくいきませんでした。 そして、私は囲aboutについて思い出しました。 信じられない-1時間の作業の後、TAR、コマンドライン、外部プロセスの開始などの小さな作業を含め、最初のバージョンを準備しました。



そのため、このプログラムはソースとともにTARを取得し、展開し、各ファイルをコンパイラーで実行します。



ここで私が書いていることの目標は、Goでマルチスレッドの命令型プログラムを書くことがどれほど簡単で便利かを示すことです(それ以上は何もありません)。



このプログラムで使用される主な概念はチャネルです。 チャネルを介して、スレッド間でデータと関数を同期的に転送できます( Go-routines )。



さらに、ソースを見ることができます。 最も興味深い場所は、関数 "compile()"を変更せずに複数のスレッドから呼び出す方法を確認できる場所です。



package main import ( "archive/tar" "container/vector" "exec" "flag" "fmt" "io" "os" "strings" ) //  :     . var jobs *int = flag.Int("jobs", 0, "number of concurrent jobs") var compiler *string = flag.String("cc", "bcom", "compiler name") func main() { flag.Parse() os.Args = flag.Args() args := os.Args ar := args[0] r, err := os.Open(ar, os.O_RDONLY, 0666); if err != nil { fmt.Printf("unable to open TAR %s\n", ar) os.Exit(1) } // defer -   "finally {}",   //     . defer r.Close() //   TAR. fmt.Printf("- extracting %s\n", ar) //    . tr := tar.NewReader( r ) tests := new(vector.StringVector) //    ,     //   . for { //      . hdr, _ := tr.Next() if hdr == nil { break } name := &hdr.Name //     ,  . if !strings.HasPrefix(*name, "HDR_") { tests.Push(*name) } //   . w, err := os.Open("data/" + *name, os.O_CREAT | os.O_RDWR, 0666) if err != nil { fmt.Printf("unable to create %s\n", *name) os.Exit(1) } //     . io.Copy(w, tr) w.Close() } fmt.Printf("- compiling...\n") *compiler , _ = exec.LookPath(*compiler) fmt.Printf("- compiler %s\n", *compiler) if *jobs == 0 { //  "compile()" ,   . fmt.Printf("- running sequentially\n") for i := 0; i < tests.Len(); i++ { compile(tests.At(i)) } } else { //  "compile()"   . fmt.Printf("- running %d concurrent job(s)\n", *jobs) //  :        , //   . -runner'   //    .     // .   ,    // ,   runner' . tasks := make(chan string, *jobs) //     -runner'. //    ,   runner'  //   .     . done := make(chan bool) //  runner'. for i := 0; i < *jobs; i++ { go runner(tasks, done) } //       .  //    ,   //  . for i := 0; i < tests.Len(); i++ { tasks <- tests.At(i) } //        //       . for i := 0; i < *jobs; i++ { tasks <- "" <- done } } } // -runner. func runner(tasks chan string, done chan bool) { //  . for { //    . ,   //   . name := <- tasks //   ,   . if len(name) == 0 { break } //  . compile(name) } //  ,   . done <- true } func compile(name string) { //  . c, err := exec.Run(*compiler, []string{*compiler, name}, os.Environ(), "./data", exec.DevNull, exec.PassThrough, exec.PassThrough) if err != nil { fmt.Printf("unable to compile %s (%s)\n", name, err.String()) os.Exit(1) } c.Wait(0) }
      
      





メイクファイル:



 target = tar_extractor all: 6g $(target).go 6l -o $(target) $(target).6
      
      





私は、8プロセッサブレード上のLinux 64ビットでこのようなものを運転しました。 テスト中、私はマシン上で一人でいたので、異なる実行の結果を比較できます。 ファイル「huge.tar」には、約15,000のソースが含まれ、サイズは1ギガバイトです。



これは、マシンが何もしない場合のプロセッサーのロードの様子です(すべてのプロセッサーはほぼ100%アイドルです)。



  Cpu0 : 0.0%us, 0.0%sy, 0.0%ni,100.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu1 : 0.0%us, 0.0%sy, 0.0%ni, 99.7%id, 0.3%wa, 0.0%hi, 0.0%si, 0.0%st Cpu2 : 0.0%us, 0.0%sy, 0.0%ni,100.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu3 : 0.0%us, 0.0%sy, 0.0%ni,100.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu4 : 0.0%us, 0.0%sy, 0.0%ni,100.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu5 : 0.0%us, 0.3%sy, 0.0%ni, 99.3%id, 0.3%wa, 0.0%hi, 0.0%si, 0.0%st Cpu6 : 0.0%us, 0.0%sy, 0.0%ni,100.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu7 : 0.0%us, 0.0%sy, 0.0%ni,100.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
      
      





順次モードで実行(-jobs 0):



  make && time -p ./tar_extractor -jobs 0 huge.tar
      
      





作業時間:



  real 213.81 user 187.32 sys 61.33
      
      





すべてのプロセッサのほぼ70〜80%は何もしません(コンパイル段階ですべての写真を撮りました)。



  Cpu0 : 11.9%us, 4.3%sy, 0.0%ni, 82.5%id, 1.3%wa, 0.0%hi, 0.0%si, 0.0%st Cpu1 : 9.6%us, 2.7%sy, 0.0%ni, 87.7%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu2 : 4.3%us, 1.3%sy, 0.0%ni, 92.7%id, 1.7%wa, 0.0%hi, 0.0%si, 0.0%st Cpu3 : 16.0%us, 6.0%sy, 0.0%ni, 78.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu4 : 12.6%us, 4.3%sy, 0.0%ni, 82.7%id, 0.3%wa, 0.0%hi, 0.0%si, 0.0%st Cpu5 : 11.6%us, 3.3%sy, 0.0%ni, 85.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu6 : 4.7%us, 1.3%sy, 0.0%ni, 94.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu7 : 16.6%us, 6.3%sy, 0.0%ni, 77.1%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
      
      





プロセッサの合計負荷は2.7%です。



  PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 15054 tester 18 0 41420 4980 1068 S 2.7 0.1 0:02.96 tar_extractor
      
      





スレッドプールから始めますが、チャネルは1つだけです(-jobs 1)。



時間:



  real 217.87 user 191.42 sys 62.53
      
      





プロセッサー:



  Cpu0 : 5.7%us, 1.7%sy, 0.0%ni, 92.7%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu1 : 13.3%us, 5.3%sy, 0.0%ni, 81.3%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu2 : 7.0%us, 2.7%sy, 0.0%ni, 89.3%id, 0.7%wa, 0.0%hi, 0.3%si, 0.0%st Cpu3 : 15.3%us, 5.7%sy, 0.0%ni, 77.7%id, 1.3%wa, 0.0%hi, 0.0%si, 0.0%st Cpu4 : 6.0%us, 2.0%sy, 0.0%ni, 92.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu5 : 14.3%us, 7.3%sy, 0.0%ni, 78.4%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu6 : 7.0%us, 2.3%sy, 0.0%ni, 90.7%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu7 : 15.3%us, 6.6%sy, 0.0%ni, 78.1%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
      
      





実際には1つのストリームも駆動するため、画像が同じであることは明らかです。



スレッドプールを有効にします(-jobs 32):



  make && time -p ./tar_extractor -jobs 32 huge.tar
      
      





稼働時間はほぼ7倍に短縮されました。



  real 38.38 user 195.55 sys 69.92
      
      





(コンパイル段階中の)プロセッサの合計負荷は23%に増加しました。



  PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 17488 tester 16 0 45900 9732 1076 S 23.6 0.1 0:06.40 tar_extractor
      
      





すべてのプロセッサが本当に忙しいことがわかります。



  Cpu0 : 56.3%us, 26.3%sy, 0.0%ni, 17.3%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu1 : 55.5%us, 27.9%sy, 0.0%ni, 15.6%id, 1.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu2 : 56.1%us, 25.9%sy, 0.0%ni, 15.0%id, 0.7%wa, 0.3%hi, 2.0%si, 0.0%st Cpu3 : 58.1%us, 26.2%sy, 0.0%ni, 15.6%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu4 : 57.2%us, 25.8%sy, 0.0%ni, 17.1%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu5 : 56.8%us, 26.2%sy, 0.0%ni, 16.9%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st Cpu6 : 59.0%us, 26.3%sy, 0.0%ni, 13.0%id, 1.7%wa, 0.0%hi, 0.0%si, 0.0%st Cpu7 : 56.5%us, 27.2%sy, 0.0%ni, 16.3%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
      
      





このテストは、最も単純な並列コードがすべてを高速化する方法を理解することのみを目的としています。 また、Goで並列コンピューティングをプログラミングできることを、いかにシンプルで比較的安全かを示すために。



どうぞ Goルーチンの効率を、C ++などのネイティブスレッドと比較してみましょう。



私は、ブーストと新しいC ++の戦闘機ではないことを認めますが、C ++のソリューションは非常にエレガントです。



両方の言語でフローの生産性を比較し、作業の作成と割り当てからの速度の面で比較することは興味深いものでした。 私が理解しているように、これはオペレーティングシステムのスレッドではないpthreadとGo-routineシステムの戦いです。 ドキュメントが言うように



ゴルーチンは複数のOSスレッドに多重化されるため、I / Oを待機している間など、ブロックする必要がある場合、他のスレッドは引き続き実行されます。 その設計は、スレッドの作成と管理の複雑さの多くを隠します。



私は最後のブーストを取り、同じ8プロセッサマシンで実験しました。



プログラムは同じ種類の作業を多く行う必要があります(実際には、関数を呼び出します)。 タスクは複数の並列スレッド間で多重化されます。 関数自体は基本的で高速です。 これにより、ペイロードではなくスレッドサブシステムにテストを集中できるようになります。



Goのプログラム:



 package main import ( "flag" "fmt" ) var jobs *int = flag.Int("jobs", 8, "number of concurrent jobs") var n *int = flag.Int("tasks", 1000000, "number of tasks") func main() { flag.Parse() fmt.Printf("- running %d concurrent job(s)\n", *jobs) fmt.Printf("- running %d tasks\n", *n) tasks := make(chan int, *jobs) done := make(chan bool) for i := 0; i < *jobs; i++ { go runner(tasks, done) } for i := 1; i <= *n; i++ { tasks <- i } for i := 0; i < *jobs; i++ { tasks <- 0 <- done } } func runner(tasks chan int, done chan bool) { for { if arg := <- tasks; arg == 0 { break } worker() } done <- true } func worker() int { return 0 }
      
      





一連のパラメーターを実行するMakefile:



 target = go_threading all: build build: 6g $(target).go 6l -o $(target) $(target).6 run: (time -p ./$(target) -tasks=$(args) \ 1>/dev/null) 2>&1 | head -1 | awk '{ print $$2 }' n = \ 10000 \ 100000 \ 1000000 \ 10000000 \ 100000000 test: @for i in $(n); do \ echo "`printf '% 10d' $$i`" `$(MAKE) args=$$i run`; \ done
      
      





C ++プログラム:



 #include <iostream> #include <boost/thread.hpp> #include <boost/bind.hpp> #include <queue> #include <string> #include <sstream> class thread_pool { typedef boost::function0<void> worker; boost::thread_group threads_; std::queue<worker> queue_; boost::mutex mutex_; boost::condition_variable cv_; bool done_; public: thread_pool() : done_(false) { for(int i = 0; i < boost::thread::hardware_concurrency(); ++i) threads_.create_thread(boost::bind(&thread_pool::run, this)); } void join() { threads_.join_all(); } void run() { while (true) { worker job; { boost::mutex::scoped_lock lock(mutex_); while (queue_.empty() && !done_) cv_.wait(lock); if (queue_.empty() && done_) return; job = queue_.front(); queue_.pop(); } execute(job); } } void execute(const worker& job) { job(); } void add(const worker& job) { boost::mutex::scoped_lock lock(mutex_); queue_.push(job); cv_.notify_one(); } void finish() { boost::mutex::scoped_lock lock(mutex_); done_ = true; cv_.notify_all(); } }; void task() { volatile int r = 0; } int main(int argc, char* argv[]) { thread_pool pool; int n = argc > 1 ? std::atoi(argv[1]) : 10000; int threads = boost::thread::hardware_concurrency(); std::cout << "- executing " << threads << " concurrent job(s)" << std::endl; std::cout << "- running " << n << " tasks" << std::endl; for (int i = 0; i < n; ++i) { pool.add(task); } pool.finish(); pool.join(); return 0; }
      
      





メイクファイル:



 BOOST = ~/opt/boost-1.46.1 target = boost_threading build: g++ -O2 -I $(BOOST) -o $(target) \ -lpthread \ -lboost_thread \ -L $(BOOST)/stage/lib \ $(target).cpp run: (time -p LD_LIBRARY_PATH=$(BOOST)/stage/lib ./$(target) $(args) \ 1>/dev/null) 2>&1 | head -1 | awk '{ print $$2 }' n = \ 10000 \ 100000 \ 1000000 \ 10000000 \ 100000000 test: @for i in $(n); do \ echo "`printf '% 10d' $$i`" `$(MAKE) args=$$i run`; \ done
      
      





両方の言語で、スレッドの数はプロセッサの数-8に等しくなります。これらの8つのスレッドを介して実行されるタスクの数は異なります。



C ++でプログラムを実行します。



 make && make -s test g++ -O2 -I ~/opt/boost-1.46.1 -o boost_threading \ -lpthread \ -lboost_thread \ -L ~/opt/boost-1.46.1/stage/lib \ boost_threading.cpp (time -p LD_LIBRARY_PATH=~/opt/boost-1.46.1/stage/lib ./boost_threading \ 1>/dev/null) 2>&1 | head -1 | awk '{ print $2 }' 10000 0.03 100000 0.35 1000000 3.43 10000000 29.57 100000000 327.37
      
      





さあ行く:



 make && make -s test 6g go_threading.go 6l -o go_threading go_threading.6 10000 0.00 100000 0.03 1000000 0.35 10000000 3.72 100000000 38.27
      
      





違いは明らかです。



ところで、変数GOMAXPROCS = 8を設定すると、Goランタイムに8つのプロセッサすべてを使用するように指示する(デフォルトでは、1つだけが使用され、物理プロセッサが非常に多い)ため、Goのプログラムの速度は大幅に低下し、C ++のプログラムの時間とほぼ等しくなります ネイティブスレッドに多重化された軽量のGoスレッドがより高速に動作することがわかりました。



たぶん私は塩味と赤を比較し、結果は単に不十分です。 オウムを正しく測定するためのヒントをいただければ幸いです。



Goの投稿:



All Articles