なぜ MapReduce が必要なのでしょうか?
実際のビジネス開発シナリオでは、複雑なオブジェクトを組み立てるために、さまざまな rpc サービスやさまざまな呼び出し関数から対応する属性を取得する必要があることがよくあります。たとえば、製品の詳細をクエリします。
製品サービス – 製品属性のクエリ
在庫サービス – 在庫属性のクエリ 価格
サービス – 価格属性のクエリ
マーケティング サービス – マーケティング属性のクエリ
シリアル呼び出しの場合、応答時間は rpc サービス呼び出しの数に比例して増加するため、パフォーマンスを最適化したい場合は、通常、シリアルをパラレルに変更します。
WaitGroup を使用すると、単純なシナリオではニーズを満たすことができますが、rpc 呼び出し、データの処理と変換、データの要約などによって返されたデータを検証する必要がある場合、WaitGroup の使用は少し不十分です。MapReduceはインプロセスデータのバッチ処理を実装します。
実際の開発では、基本的に入力データを加工し、クリーン化したデータを出力することになりますが、データ加工の大まかな流れは以下の3つのステップに分かれます。
データ生成生成
データ処理マッパー データ
集約リデューサー
. データ生成は必須の段階です. データ処理とデータ集約はオプションの段階です. データ生成と処理は同時呼び出しをサポートします. データ集約は基本的に単一のコルーチンによる純粋なメモリ操作です. ゴルーチンによってさまざまなステージを同時にスケジュールできるため、ステージによって生成されたデータは自然にチャネルを使用してゴルーチン間の通信を実現できます。
MapReduce の基本的な使い方
go-zero フレームワークによってカプセル化された MapReduce のアドレスは次のとおりです。
import "github.com/zeromicro/go-zero/core/mr"
このメソッドは次のように定義されます (1.5.0 はジェネリックスをサポートします)。
func MapReduce[T any, U any, V any](generate GenerateFunc[T], mapper MapperFunc[T, U], reducer ReducerFunc[U, V], opts ...Option) (V, error)
MapReduce がどのように使用されるかを確認するために、並列二乗和の例を見てみましょう。
package main
import (
"fmt"
"log"
"github.com/zeromicro/go-zero/core/mr"
)
func main() {
val, err := mr.MapReduce(func(source chan<- int) {
// generator
for i := 0; i < 10; i++ {
source <- i
}
}, func(i int, writer mr.Writer[int], cancel func(error)) {
// mapper
fmt.Println(i)
writer.Write(i * i)
}, func(pipe <-chan int, writer mr.Writer[int], cancel func(error)) {
// reducer
var sum int
for i := range pipe {
sum += i
}
writer.Write(sum)
})
if err != nil {
log.Fatal(err)
}
fmt.Println("result:", val)
}
MapReduce は 3 つの必須パラメータ、つまり、generate、mapper、reducer を渡す必要があります。これらはすべてカスタム関数タイプで、順番にデータ生成、データ処理、データ集計に対応します。オプションのパラメーターは、同時コルーチンの数を開始するためのコンテキスト コンテキストとワーカーをサポートします。
MapReduce の実装原理
MapReduce で定義されるデータ構造は、Go の同時実行プリミティブである WaitGroup、Once、Channel、Context、atomic を順に使用すると以下のようになります。
const (
defaultWorkers = 16
minWorkers = 1
)
var (
// ErrCancelWithNil is an error that mapreduce was cancelled with nil.
ErrCancelWithNil = errors.New("mapreduce cancelled with nil")
// ErrReduceNoOutput is an error that reduce did not output a value.
ErrReduceNoOutput = errors.New("reduce not writing value")
)
type (
// GenerateFunc is used to let callers send elements into source.
GenerateFunc[T any] func(source chan<- T)
// MapperFunc is used to do element processing and write the output to writer,
// use cancel func to cancel the processing.
MapperFunc[T, U any] func(item T, writer Writer[U], cancel func(error))
// ReducerFunc is used to reduce all the mapping output and write to writer,
// use cancel func to cancel the processing.
ReducerFunc[U, V any] func(pipe <-chan U, writer Writer[V], cancel func(error))
// MapFunc is used to do element processing and write the output to writer.
MapFunc[T, U any] func(item T, writer Writer[U])
// Option defines the method to customize the mapreduce.
Option func(opts *mapReduceOptions)
mapperContext[T, U any] struct {
ctx context.Context
mapper MapFunc[T, U]
source <-chan T
panicChan *onceChan
collector chan<- U
doneChan <-chan struct{
}
workers int
}
mapReduceOptions struct {
ctx context.Context
workers int
}
// Writer interface wraps Write method.
Writer[T any] interface {
Write(v T)
}
)
defaultWorkers: デフォルト構成では、同時実行のために 16 個のコルーチンが開始され、カスタマイズされた構成が可能になります。
GenerateFunc[T any] func(source chan<- T): データ生成。渡されるパラメーターはチャンネルで、データを Mapper に渡すために使用されます。
MapperFunc[T, U any] func(item T, Writer Writer[U], cancel func(error)): データ処理、取得したアイテムをさらに処理し、結果をライターに書き込みます。エラーが発生した場合は呼び出すことができます。 cancel は後続のリクエストをキャンセルします。
ReducerFunc[U, V any] func(pipe <-chan U, Writer Writer[V], cancel func(error)): データ集約。Mapper で処理された値をパイプラインから取得し、結果を追加してライターに要約します。そして戻ってきます。
MapFunc[T, U any] func(item T, Writer Writer[U]): Mapper の特定のプロシージャ呼び出しに使用されます。
Option func(opts *mapReduceOptions): オプションのパラメータ設定
mapperContext[T, U any] struct {}: Mapper の処理中にメタデータをカプセル化する構造を定義します。
Writer[T any] インターフェイス {}: コルーチン フロー中にデータを書き込むためのインターフェイスを定義します。
その他の補助構造は次のとおりです。
// WithContext customizes a mapreduce processing accepts a given ctx.
func WithContext(ctx context.Context) Option {
return func(opts *mapReduceOptions) {
opts.ctx = ctx
}
}
// WithWorkers customizes a mapreduce processing with given workers.
func WithWorkers(workers int) Option {
return func(opts *mapReduceOptions) {
if workers < minWorkers {
opts.workers = minWorkers
} else {
opts.workers = workers
}
}
}
func buildOptions(opts ...Option) *mapReduceOptions {
options := newOptions()
for _, opt := range opts {
opt(options)
}
return options
}
func newOptions() *mapReduceOptions {
return &mapReduceOptions{
ctx: context.Background(),
workers: defaultWorkers,
}
}
func once(fn func(error)) func(error) {
once := new(sync.Once)
return func(err error) {
once.Do(func() {
fn(err)
})
}
}
type guardedWriter[T any] struct {
ctx context.Context
channel chan<- T
done <-chan struct{
}
}
func newGuardedWriter[T any](ctx context.Context, channel chan<- T, done <-chan struct{
}) guardedWriter[T] {
return guardedWriter[T]{
ctx: ctx,
channel: channel,
done: done,
}
}
func (gw guardedWriter[T]) Write(v T) {
select {
case <-gw.ctx.Done():
return
case <-gw.done:
return
default:
gw.channel <- v
}
}
type onceChan struct {
channel chan any
wrote int32
}
func (oc *onceChan) write(val any) {
if atomic.CompareAndSwapInt32(&oc.wrote, 0, 1) {
oc.channel <- val
}
}
次に、MapReduce がどのように実装されるかを見てみましょう。
// MapReduce maps all elements generated from given generate func,
// and reduces the output elements with given reducer.
func MapReduce[T, U, V any](generate GenerateFunc[T], mapper MapperFunc[T, U], reducer ReducerFunc[U, V],
opts ...Option) (V, error) {
panicChan := &onceChan{
channel: make(chan any)}
source := buildSource(generate, panicChan)
return mapReduceWithPanicChan(source, panicChan, mapper, reducer, opts...)
}
まず、データ生成プロセスを見てみましょう。このプロセスでは、buildSource メソッドをカプセル化し、実行用のコルーチンを開始し、結果をソースに書き込みます。
func buildSource[T any](generate GenerateFunc[T], panicChan *onceChan) chan T {
source := make(chan T)
go func() {
defer func() {
if r := recover(); r != nil {
panicChan.write(r)
}
close(source)
}()
generate(source)
}()
return source
}
次に、データ処理プロセスを見てみましょう。そのカプセル化メソッドは、mapReduceWithPanicChan にあります。mapReduceWithPanicChan は比較的長いですが、次の 3 つのステップに要約できます。
パラメーター定義の
出力: 最終的な戻り結果はリデューサーによって書き込まれ、一度だけ書き込むことができます。
コレクター: バッファーされたチャン。マッパーの実行結果をコレクターに入れ、同時実行数を制限します。
Writer: チャネルにデータを書き込むためのライターをインスタンス化します。
新しいコルーチンを開始し、リデューサーを実行します。
// mapReduceWithPanicChan maps all elements from source, and reduce the output elements with given reducer.
func mapReduceWithPanicChan[T, U, V any](source <-chan T, panicChan *onceChan, mapper MapperFunc[T, U],
reducer ReducerFunc[U, V], opts ...Option) (val V, err error) {
options := buildOptions(opts...)
// output is used to write the final result
output := make(chan V)
defer func() {
// reducer can only write once, if more, panic
for range output {
panic("more than one element written in reducer")
}
}()
// collector is used to collect data from mapper, and consume in reducer
collector := make(chan U, options.workers)
// if done is closed, all mappers and reducer should stop processing
done := make(chan struct{
})
writer := newGuardedWriter(options.ctx, output, done)
var closeOnce sync.Once
// use atomic type to avoid data race
var retErr errorx.AtomicError
finish := func() {
closeOnce.Do(func() {
close(done)
close(output)
})
}
cancel := once(func(err error) {
if err != nil {
retErr.Set(err)
} else {
retErr.Set(ErrCancelWithNil)
}
drain(source)
finish()
})
go func() {
defer func() {
drain(collector)
if r := recover(); r != nil {
panicChan.write(r)
}
finish()
}()
reducer(collector, writer, cancel)
}()
go executeMappers(mapperContext[T, U]{
ctx: options.ctx,
mapper: func(item T, w Writer[U]) {
mapper(item, w, cancel)
},
source: source,
panicChan: panicChan,
collector: collector,
doneChan: done,
workers: options.workers,
})
select {
case <-options.ctx.Done():
cancel(context.DeadlineExceeded)
err = context.DeadlineExceeded
case v := <-panicChan.channel:
// drain output here, otherwise for loop panic in defer
drain(output)
panic(v)
case v, ok := <-output:
if e := retErr.Load(); e != nil {
err = e
} else if ok {
val = v
} else {
err = ErrReduceNoOutput
}
}
return
}
マッパーexecuteMappersの具体的な実行メソッドを見てみましょう。
func executeMappers[T, U any](mCtx mapperContext[T, U]) {
var wg sync.WaitGroup
defer func() {
wg.Wait()
close(mCtx.collector)
drain(mCtx.source)
}()
var failed int32
pool := make(chan struct{
}, mCtx.workers)
writer := newGuardedWriter(mCtx.ctx, mCtx.collector, mCtx.doneChan)
for atomic.LoadInt32(&failed) == 0 {
select {
case <-mCtx.ctx.Done():
return
case <-mCtx.doneChan:
return
case pool <- struct{
}{
}:
item, ok := <-mCtx.source
if !ok {
<-pool
return
}
wg.Add(1)
go func() {
defer func() {
if r := recover(); r != nil {
atomic.AddInt32(&failed, 1)
mCtx.panicChan.write(r)
}
wg.Done()
<-pool
}()
mCtx.mapper(item, writer)
}()
}
}
}
WaitGroup と Channel に基づいてコルーチンの同時実行を開始し、結果をライターに書き込み、パニック例外を適切に処理します。
概要:
MapReduce は Go 言語に付属する同時実行プリミティブをデータ処理の利用シナリオに基づいてカプセル化し、データ生成 – データ処理 – データ集計の全プロセスを実現するもので、私の実際の開発でもよく利用しています。このパッケージには、紹介した MapReduce 以外にもいくつかの実装が含まれていますが、それらは類似しています。興味のある読者は、ソース コードを自分で読んで、MapReduce についての理解を深め、前に紹介した同時実行プリミティブを確認することができます。