分叉-合併與管線模式(fork-join and the pipeline pattern)
平行工作有兩種日常的形狀。第一種:要數一間巨大圖書館裡的書,把館舍分成幾翼,同時派一隊人到每一翼,再把他們的計數加總。這就是分叉-合併——把工作分叉成獨立的子任務、讓它們平行跑,再合併(等全部完成並合併結果)。第二種:汽車裝配線,每一站做一個步驟、再把車傳給下一站,於是許多輛車同時在不同站台進行中。這就是管線——工作流過一連串階段,每個階段並行地在不同的物件上運行。
分叉-合併是分而治之的模式:一個任務把它的問題切成更小的獨立片段(分叉),這些片段並行地跑(理想上跑在工作竊取池上,這完美契合遞迴的形狀),父任務在合併處阻塞,直到所有子任務完成,再合併它們的結果。parallel-for 是扁平的特例——把同一個操作套到陣列每個元素上、分散給許多工作者;而 map-reduce 是對資料的分叉-合併:平行地 map 每一塊,再 reduce(合併)那些部分結果。管線(或資料流)則改為串接階段 s1 -> s2 -> s3,以通道連接:階段 1 把物件生產進一個通道,階段 2 消費並轉換它們到下一個通道,依此類推。因為各階段跑在不同執行緒上,當階段 2 在處理第 5 個物件時,階段 1 已經在生產第 6 個了——吞吐量受限於最慢的階段,而非所有階段的總和。這兩種模式可以組合:一個管線階段本身內部也能做分叉-合併。
它們之所以重要,是因為多數真實的平行歸結為這兩種形狀之一,而替它們命名,就告訴了你各自該怎麼推理。分叉-合併談的是單一大任務的延遲:更多核心能更快做完它,而 Am's 定律(Amdahl's law)以你無法平行化的那部分為其加速設下上限。管線談的是串流的吞吐量:它讓階段重疊,但單一物件仍要串列地走過每一階段,所以它砍的是吞吐量上限,不是每件物件的延遲。誠實的提醒:分叉-合併只有在子任務真正獨立、且大到足以蓋過生成與合併它們的成本時才有用——切得太細會讓額外開銷主導;而管線快不過它最慢的階段,且其通道需要背壓,免得快的階段淹沒慢的階段。
分叉-合併求和(Rust/rayon):let total = data.par_iter().map(square).sum(); // 在池上分叉、合併各部分——一次 map-reduce。管線:producer -> 通道 -> parse -> 通道 -> write,三個階段、三條執行緒、彼此重疊。
分叉-合併縮短單一大任務的延遲;管線讓階段重疊以提高串流的吞吐量。
切得太細會讓生成/合併的開銷主導——分叉-合併需要大到值得的子任務。管線快不過它最慢的階段,而沒有背壓時快階段會淹沒慢階段。替模式命名,是把它調對大小的第一步。