Go Diary-Goをもっと速くしたいですか?

译自>https://bravenewgeek.com/so-you-wanna-go-fast/

高性能Goを作成するためのヒント

これまでのところ、私が書いていることを忘れていますが、この記事はGoに関するものであることを保証します。これは真実であり、配信速度ではなくパフォーマンスの向上に大きく依存します。この2つはしばしば互いに矛盾します。これまでのところ、すべてが役に立たないコンテキストと苦情です。しかし、それはまた、私たちがいくつかの問題を解決していること、そしてなぜ私たちが現状を維持したいのかを示しています。常に歴史があります。

私は多くの賢い人々と仕事をしています。私たちの多くはパフォーマンスにほとんど夢中になっていますが、先に指摘したことの1つは、クラウドソフトウェアの予想範囲を超えようとしていることです。App Engineにはいくつかの厳しい境界があるため、変更を加えました。Goを採用して以来、システムプログラミングの分野で、物事をより速くする方法とGoを機能させる方法について多くのことを学びました。

Goのシンプルさと同時実行性モデルは、バックエンドシステムにとって魅力的な選択肢ですが、より大きな問題は、遅延の影響を受けやすいアプリケーションにどのように役立つかということです。言語を速くするために、言語の単純さを犠牲にする必要がありますか?Goのパフォーマンス最適化のいくつかの側面、つまり言語機能、メモリ管理、同時実行性を徐々に理解し、決定を下してみましょう。ここで提供されるすべてのベンチマークコード、GitHubにあります

チャネル

Goのチャネルは、便利な同時実行プリミティブであるため多くの注目を集めていますが、パフォーマンスへの影響を理解することが重要です。一般に、ほとんどの場合、パフォーマンスは「十分」ですが、レイテンシーの要件が厳しい場合は、ボトルネックが発生する可能性があります。チャネルは魔法ではありません。内部では、彼らはただロックをしているだけです。これは、ロックの競合がないシングルスレッドアプリケーションではうまく機能しますが、マルチスレッド環境では、パフォーマンスが大幅に低下します。ロックフリーのリングバッファを使用してチャネルのセマンティクスを簡単に模倣できます。

最初のベンチマークテストでは、単一のプロデューサーと単一のコンシューマーを使用した単一のバッファーチャネルとリングバッファーのパフォーマンスを調べました。まず、シングルスレッドの場合(GOMAXPROCS = 1)のパフォーマンスを見てみましょう。

BenchmarkChannel 3000000 512 ns / op
BenchmarkRingBuffer 20000000 80.9 ns / op

ご覧のとおり、リングバッファは約6倍高速です(Goのベンチマークツールに慣れていない場合、ベンチマーク名の横の最初の数字は、安定した結果が得られるまでにベンチマークが実行された回数を示します)。次に、GOMAXPROCS = 8の同じベンチマークを確認します。

BenchmarkChannel-8 3000000 542 ns / op
BenchmarkRingBuffer-8 10000000 182 ns / op

リングバッファはほぼ3倍高速です。

チャネルは通常、作業者のグループ間で作業を分散するために使用されます。このベンチマークテストでは、バッファリングされたチャネルとリングバッファでの高い読み取り競合のパフォーマンスに焦点を当てます。GOMAXPROCS = 1の実験は、チャネルをシングルスレッドシステムで確実に使用する方法を示しています。

BenchmarkChannelReadContention 10000000 148 ns / on
BenchmarkRingBufferReadContention 10000 390195 ns / on

ただし、マルチスレッドの場合、リングバッファの方が高速です。

BenchmarkChannelReadContention-8 1000000 3105 ns / on
BenchmarkRingBufferReadContention-8 3000000 411 ns / on

最後に、リーダーとライターの両方のパフォーマンスを調査しました。同様に、リングバッファのパフォーマンスは、シングルスレッドの場合ははるかに悪くなりますが、マルチスレッドの場合は良くなります。

BenchmarkChannelContention 10000 160892 ns /
BenchmarkRingBufferContention 2 806834344 ns /
BenchmarkChannelContention-8 5000 314428 ns /
BenchmarkRingBufferContention-8 10000 182557 ns / on

ロックフリーリングバッファは、スレッドの安全性を実現するためにCAS操作のみを使用します。チャネルで使用するかどうかの決定は、プログラムで使用可能なOSスレッドの数に大きく依存していることがわかります。ほとんどのシステムでは、GOMAXPROCS> 1であるため、パフォーマンスが重要な場合は、ロックフリーのリングバッファーの方が適していることがよくあります。マルチスレッドシステムで共有状態への高性能アクセスを実行するには、チャネルはかなり不適切な選択です。

延期

Deferは、読みやすさを向上させ、リソースの解放に関連するエラーを回避するGoの便利な言語機能です。たとえば、読み取り用にファイルを開くときは、完了後に慎重に閉じる必要があります。延期がない場合は、関数の各終了ポイントでファイルが閉じていることを確認する必要があります。

func findHelloWorld(filename string) error {
        file, err := os.Open(filename)
        if err != nil {
                return err
        }
        
        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
                if scanner.Text() == "hello, world!" {
                        file.Close()
                        return nil
                }
        }

        file.Close()
        if err := scanner.Err(); err != nil {
                return err
        }
        
        return errors.New("Didn't find hello world")
}

リターンポイントを見逃しやすいので、これは間違いを犯しやすいです。Deferは、クリーンアップコードをスタックに効果的に追加し、囲んでいる関数が戻ったときにそれを呼び出すことによって、この問題を解決します。

func findHelloWorld(filename string) error {
        file, err := os.Open(filename)
        if err != nil {
                return err
        }
        defer file.Close()
        
        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
                if scanner.Text() == "hello, world!" {
                        return nil
                }
        }

        if err := scanner.Err(); err != nil {
                return err
        }
        
        return errors.New("Didn't find hello world")
}

一見すると、コンパイラはdeferステートメントを完全に最適化できると思います。関数の最初でいくつかの操作を延期した場合、関数が戻るすべてのポイントでクロージャーを挿入する必要があります。ただし、これよりも複雑です。たとえば、条件付きステートメントまたはループで呼び出しを延期できます。最初のケースでは、延期の原因となった条件を追跡する必要がある場合があります。これは関数の別の出口点であるため、コンパイラはステートメントに緊急事態が発生する可能性があるかどうかも判断できる必要があります。少なくとも表面的には、これは不確定な問題のようです。

重要なのは、Deferはゼロコストの抽象化ではないということです。ベンチマークを実行して、パフォーマンスのオーバーヘッドを示すことができます。このベンチマークでは、ミューテックスをロックし、延期を使用してループでロックを解除することと、ミューテックスをロックして延期せずにロックを解除することを比較します。

BenchmarkMutexDeferUnlock-8 20000000 96.6 ns /オン
BenchmarkMutexUnlock-8100000000 19.5 ns /オン

このテストでは、Deferの使用はほぼ5倍遅くなりました。公平を期すために、77ナノ秒の差が必要ですが、クリティカルパスのタイトなループでは、これが合計されます。これらの最適化の傾向に気付くでしょう。これは通常、パフォーマンスと開発者による読みやすさの間のトレードオフです。最適化が無料になることはめったにありません。

リフレクションとJSON

通常、反射は遅いため、遅延の影響を受けやすいアプリケーションでは避ける必要があります。JSONは一般的なデータ交換形式ですが、Goのencoding / jsonパッケージは、マーシャリングおよびアンマーシャリング構造の反映に依存しています。ffjson、我々は反射を避けるとの違いを識別するためのコード生成を使用することができます。

BenchmarkJSONReflectionMarshal-8 200000 7063 ns /オン
BenchmarkJSONMarshal-8500000 3981 ns /オン

BenchmarkJSONReflectionUnmarshal-8 200000 9362 ns /オン
BenchmarkJSONUnmarshal-8300000 5839 ns /オン

コードによって生成されたJSONは、標準ライブラリのリフレクションベースの実装よりも38%高速です。もちろん、パフォーマンスが心配な場合は、JSONの使用を完全に避ける必要があります。MessagePackの方が適切であり、シリアル化コードを生成することもできます。このベンチマークテストでは、msgpライブラリを使用して、そのパフォーマンスをJSONと比較します。

BenchmarkMsgpackMarshal-8 3000000 555 ns / at
BenchmarkJSONReflectionMarshal-8 200000 7063 ns / at
BenchmarkJSONMarshal-8 500000 3981 ns / at

BenchmarkMsgpackUnmarshal-8 20000000 94.6 ns / on
BenchmarkJSONReflectionUnmarshal-8 200000 9362 ns / on
BenchmarkJSONUnmarshal-8 300000 5839 ns / on

ここでの違いは非常に大きいです。生成されたJSONシリアル化コードと比較した場合でも、MessagePackは大幅に高速です。

本当にマイクロ最適化しようとしている場合、インターフェイスはマーシャリングするだけでなく、メソッド呼び出しのオーバーヘッドももたらすため、インターフェイスの使用を避けるように注意する必要があります。他のタイプの動的ディスパッチと同様に、実行時にメソッド呼び出しでルックアップを実行すると、間接的なオーバーヘッドが発生します。コンパイラはこれらの呼び出しをインライン化できません。

BenchmarkJSONReflectionUnmarshal-8 200000 9362 ns / at
BenchmarkJSONReflectionUnmarshalIface-8 200000 10099 ns / at

また、インターフェイスをサポートされている特定のタイプに変換するI2Tを見つけるための呼び出しのコストを調べることもできます。このベンチマークは、同じ構造で同じメソッドを呼び出します。違いは、2番目のものは構造によって実装されたインターフェースを参照することです。

BenchmarkStructMethodCall-8 2000000000 0.44 ns / on
BenchmarkIfaceMethodCall-8 1000000000 2.97 ns / on

並べ替えはより実用的な例であり、パフォーマンスの違いを示しています。このベンチマークテストでは、同じ構造でサポートされている1,000,000の構造スライスと1,000,000のインターフェイスの順序を比較しました。構造の並べ替えは、インターフェイスの並べ替えよりも92%近く高速です。

BenchmarkSortStruct-8 10 105276994 ns / op
BenchmarkSortIface-8 5 286123558 ns / op

全体として、JSONの使用はできるだけ避けてください。必要に応じて、マーシャリングコードとアンマーシャリングコードを生成してください。一般に、リフレクションとインターフェイスに依存するコードは避け、代わりに特定のタイプを使用するコードを作成するのが最善です。残念ながら、これは通常、多くの重複コードにつながるため、コード生成を通じて抽象化するのが最善です。トレードオフが再び現れます。

メモリ管理

Goは、実際にはヒープまたはスタックの割り当てをユーザーに直接開示しません。実際、「ヒープ」と「スタック」という単語は、言語仕様には含まれていません。これは、スタックとヒープに関連するすべてが技術的に実装に依存していることを意味します。もちろん、実際には、Goには各ゴルーチン用のスタックとヒープがあります。コンパイラはエスケープ分析を実行して、オブジェクトがスタックに存在できるかどうか、またはヒープに割り当てる必要があるかどうかを判断します。

当然のことながら、ヒープ割り当てを回避することが最適化の主な領域である可能性があります。以下のベンチマークに示すように、スタックに割り当てることで、高価なmalloc呼び出しを回避します。

BenchmarkAllocateHeap-8 20000000 62.3 ns / op 96 B / op1届/ブラケット
BenchmarkAllocateStack- 8100000000 11.6 ns / op 0 B / op 0 allocs / op

当然、前者はポインタをコピーするだけでよく、後者は値をコピーする必要があるため、参照による受け渡しは値による受け渡しよりも高速です。これらの違いは主に何をコピーする必要があるかに依存しますが、これらのベンチマークで使用される構造との違いはごくわずかです。一部のコンパイラの最適化は、この合成ベンチマークでも実行される可能性があることに注意してください。

BenchmarkPassByReference-8 1000000000 2.35 ns / on
BenchmarkPassByValue-8 200000000 6.36 ns / on

ただし、ヒープ割り当ての大きな問題はガベージコレクションです。多くの短期オブジェクトを作成する場合は、GCがクラッシュします。このような場合、オブジェクトプールが非常に重要になります。このベンチマークテストでは、sync.Poolをヒープ内で同じ目的で使用して 10個の同時ゴルーチンの割り当て構造を比較しましたマージすると、パフォーマンスが5倍向上します。

BenchmarkConcurrentStructAllocate-8 5000000 337 ns /オン
BenchmarkConcurrentStructPool-820000000 65.5 ns /オン

Goのsync.Poolがガベージコレクション中に使い果たされたことを指摘しておく必要があります。sync.Pool目的は、ガベージコレクション間でメモリを再利用することです。ガベージコレクターの目的を損なう可能性はありますが、ガベージコレクションサイクル全体を通じてメモリに保存する空きオブジェクトの独自のリストを維持できます。Goのpprofツールは、メモリ使用量の分析に非常に役立ちます。やみくもにメモリの最適化を行う前に使用してください。

誤った共有

パフォーマンスが本当に重要な場合は、ハードウェアレベルで考え始める必要があります。フォーミュラワンのドライバー、ジャッキー・スチュワートはかつて、「レーサーになるためにエンジニアである必要はありませんが、メカニズムを理解する考え方が必要です」と有名な​​言葉を述べました。車の内部構造を深く理解しているあなたをより良いドライバーにしてください。同様に、コンピューターが実際にどのように機能するかを知ることは、あなたをより良いプログラマーにするでしょう。たとえば、メモリをどのように配置するのですか?CPUキャッシュはどのように機能しますか?ハードドライブはどのように機能しますか?

最新のCPUアーキテクチャでは、メモリ帯域幅は依然として限られたリソースであるため、パフォーマンスのボトルネックを防ぐためにキャッシングは非常に重要です。最新のマルチプロセッサCPUは、コストのかかるメインメモリアクセスを回避するために、データをより小さな行(通常は64バイトのサイズ)にキャッシュしますメモリに書き込むと、CPUキャッシュが回線を削除して、キャッシュの一貫性を維持します。その後このアドレスを読み取るには、キャッシュラインを更新する必要があります。これは誤った共有と呼ばれる現象であり、複数のプロセッサが同じキャッシュライン内の独立したデータにアクセスする場合に特に問題になります。

Goの構造とメモリ内のレイアウトを想像してみてください。例として、以前のリングバッファ取り上げましょう構造は通常次のようになります。

type RingBuffer struct {
	queue          uint64
	dequeue        uint64
	mask, disposed uint64
	nodes          nodes
}

キューフィールドとデキューフィールドは、それぞれプロデューサーとコンシューマーの場所を決定するために使用されます。これらのフィールドはすべて8バイトであり、キューからアイテムを追加および削除するために、複数のスレッドによって同時にアクセスおよび変更されます。これらの2つのフィールドはメモリに連続して配置され、16バイトのメモリしか占有しないため、単一のCPUキャッシュラインに格納される可能性があります。したがって、一方に書き込むと、もう一方が削除されます。つまり、後続の読み取りが停止します。具体的には、リングバッファのコンテンツを追加または削除すると、後続の操作が遅くなり、CPUキャッシュで多くの混乱が発生します。

フィールド間にパディングを追加することで、構造を変更できます。各パディングは、フィールドが異なる行で終了することを保証するための単一のCPUキャッシュ行の幅です。最終的には次のようになります。

type RingBuffer struct {
	_padding0      [8]uint64
	queue          uint64
	_padding1      [8]uint64
	dequeue        uint64
	_padding2      [8]uint64
	mask, disposed uint64
	_padding3      [8]uint64
	nodes          nodes
}

CPUキャッシュラインを埋める際の実際の違いはどのくらいですか?何でもそうですが、それは状況によって異なります。マルチプロセッシングの量によって異なります。競合の量によって異なります。メモリレイアウトによって異なります。考慮すべき要素はたくさんありますが、決定をサポートするために常にデータを使用する必要があります。実際の違いを理解するために、パディングの有無にかかわらずリングバッファをベンチマークできます。

まず、単一のプロデューサーと単一のコンシューマーをベンチマークします。それぞれがゴルーチンで実行されます。このテストでは、充填済みと未充填の改善は非常に小さく、約15%です。

BenchmarkRingBufferSPSC-8 10000000 156 ns / op
BenchmarkRingBufferPaddedSPSC-8 10000000 132 ns / op

ただし、複数のプロデューサーと複数のコンシューマー(それぞれ100など)がある場合、違いはより明白になります。この場合、充填バージョンの速度は約36%増加しました。

BenchmarkRingBufferMPMC-8 100000 27763 ns / op
BenchmarkRingBufferPaddedMPMC-8 100000 17860 ns / op

誤った共有は非常に現実的な問題です。同時実行性とメモリ競合の量によっては、その影響を軽減するためにパディングを導入する必要がある場合があります。これらの数値は些細なことのように思えるかもしれませんが、特にすべてのクロックサイクルが重要な場合は、合計され始めます。

ロックなし

複数のコアを最大限に活用するには、ロックフリーのデータ構造が不可欠です。Goは非常に同時の使用例を対象としていることを考えると、多くのロックフリーの方法を提供していません。励ましは主にチャネルを対象としているようであり、相互に排他的ではありません。

言い換えると、標準ライブラリは、通常の低レベルのメモリプリミティブアトミックパッケージを提供します比較と交換、アトミックポインタアクセス-すべてがそこにあります。ただし、アトミックパッケージ使用しないことを強くお勧めします。

私たちは通常、同期/アトミックをまったく使用したくありません...経験から、アトミック操作を使用する正しいコードを記述できる人はほとんどいないことが何度も示されています...同期を追加するときに内部パッケージ/アトミックパッケージを考えると、おそらくそれを使用します。現在、Go 1の保証により、パッケージを削除することはできません。

ロックを解除するのはどれくらい難しいですか?CASをこすり、Tianという名前を付けてください。十分にうぬぼれた後、私はこれが間違いなく両刃の剣であることを理解し始めました。ロックフリーコードはすぐに複雑になる可能性があります。アトミックで安全でないソフトウェアパッケージは、少なくとも最初は使いやすいものではありません。後者は理由から名付けられました。軽く踏む-これは危険な領域です。さらに重要なことに、ロックのないアルゴリズムを作成するのは難しい場合があり、エラーが発生しやすくなります。単純なロックフリーのデータ構造(リングバッファなど)は管理が非常に簡単ですが、それ以外はすべて面倒になり始めます。

Ctrieが、私は詳細に書いた、世界のキューとリストを超越ロックフリーのデータ構造に巻き込まための私の標準的な料金です。理論はかなり理解できますが、その実装は非常に複雑です。実際、複雑さは主に、ネイティブの二重比較と交換がないためです。これには、間接ノード(ツリーの変異を検出するため)とノード生成(ツリースナップショットを検出するため)のアトミック比較が必要です。この種の操作を提供するハードウェアはないため、標準のプリミティブを使用してエミュレートする必要があります(およびcan)。

実際、最初のCtrieの実装は、Goの同期プリミティブを正しく使用しなかったためでも、ひどく壊れていました。代わりに、私は言語について間違った仮定をしました。Ctrieの各ノードには、世代が関連付けられています。ツリーのスナップショットを作成すると、そのルートノードが新しい世代にコピーされます。ツリー内のノードにアクセスすると、ノードは新しい世代(永続データ構造と呼ばれます)に遅延コピーされるため、一定時間のスナップショットを取得できます。整数のオーバーフローを回避するために、ヒープに割り当てられたオブジェクトを使用して世代を分割します。Goでは、これは空の構造を使用して行われます。Javaでは、新しく構築された2つのオブジェクトは、メモリアドレスが異なるため、比較すると同等ではありません。Goでも同じことが盲目的に当てはまると思いますが、そうではありません。文字通り、Go言語の仕様は次のとおりです。

構造または配列タイプにゼロより大きいサイズのフィールド(または要素)が含まれていない場合、そのサイズはゼロです。2つの異なるサイズのゼロ変数は、メモリ内に同じアドレスを持つ場合があります。

くそー。その結果、2つの異なる世代が同等であると見なされるため、二重の比較と交換は常に成功します。これにより、スナップショットがツリーを不整合な状態のままにする可能性があります。これは興味深いエラーであり、追跡する価値があります。同時性の高いロックフリーコードのデバッグは非常に面倒です。初めて正しく理解できなかった場合は、修正に多くの時間を費やしますが、それは非常に微妙なエラーがある場合に限られます。そして、あなたが最初に正しいとは考えにくいです。今回はイアン・ランス・テイラーが勝ちました。

ちょっと待って!明らかに、複雑なロックフリーアルゴリズムの使用は報われるでしょう、またはなぜ誰かがこれを制限する必要がありますか?Ctrieを使用すると、検索パフォーマンスを同期マッピングまたは同時スキップリストに匹敵することができます。間接性が高まるため、挿入はより高価になります。Ctrieの本当の利点は、メモリ消費の観点からのスケーラビリティです。これは、常にツリーに現在存在するキーの数の関数であるという点で、ほとんどのハッシュテーブルとは異なります。もう1つの利点は、一定時間の線形化されたスナップショットを実行できることです。Ctrieを使用して比較し、同じテストを使用して、100の異なるゴルーチンで同期マップに対して「スナップショット」を同時に実行することができます。

BenchmarkConcurrentSnapshotMap-8 1000 9941 784 ns / on
BenchmarkConcurrentSnapshotCtrie-8 20000 90412 ns / on

アクセスモードによっては、ロックフリーのデータ構造により、マルチスレッドシステムのパフォーマンスが向上します。たとえば、NATSメッセージバスは、同期マップベースの構造を使用してサブスクリプションマッチングを実行します。Ctrieに触発されたロックフリー構造と比較して、スループットははるかにスケーラブルです。青い線はロックベースのデータ構造で、赤い線はロックフリーの実装です。

[外部リンク画像の転送に失敗しました。ソースサイトにリーチ防止リンクメカニズムがある可能性があります。画像を保存して直接アップロードすることをお勧めします(img-K72NtSbt-1570527660635)(leanote:// file / getImage?fileId = 5d9c4bb066be486e4c000004)]

状況によっては、ロックを回避することが有益な場合があります。リングバッファをチャネルと比較すると、利点は明らかです。それでも、コードの複雑さと利点を比較検討することが重要です。実際、ロックがまったく明らかな利点を提供しない場合があります。

最適化に関する注意事項

この記事全体で見てきたように、パフォーマンスの最適化にはほとんどの場合、代償が伴います。最適化自体を特定して理解することは、最初のステップにすぎません。それらをいつどこに適用するかを理解することがより重要です。DonaldKnuthによって宣伝されたCARHoareの有名な引用は、プログラマーの長期的なモットーになっています。

本当の問題は、プログラマーが間違った場所にいることや間違った時間効率を心配することに多くの時間を費やしていることです。時期尚早の最適化は、プログラミングにおけるすべての悪(または少なくともほとんどの悪)の根源です。

この文のポイントは最適化を完全に排除することではなく、速度のバランスをとる方法を学ぶことですが、これらの速度には、アルゴリズム速度、配信速度、保守速度、およびシステム速度が含まれます。これは非常に主観的なトピックであり、大まかなルールはありません。時期尚早の最適化はすべての悪の根源ですか?私はそれを機能させてからそれを速くするべきですか?高速である必要がありますか?これらは二者択一ではありません。たとえば、設計に根本的な問題がある場合、それを機能させてから迅速に実行することが不可能な場合があります。

ただし、重要なパスに沿って最適化し、必要に応じてそのパスから外側に拡張することに重点を置いてください。クリティカルパスから離れるほど、投資収益率が低下する可能性が高くなり、最終的にはより多くの時間が無駄になります。何が十分なパフォーマンスであるかを判断することが重要です。それ以上の時間を費やさないでください。この分野では、データ主導の意思決定が不可欠であり、衝動的ではなく経験的です。さらに重要なことに、私たちは実用的でなければなりません。それが問題でなければ、10億分の1秒の操作を減らすことは無意味です。高速実行は、コードの高速実行以上のものです。

一般に

おめでとうございます。これまでに行ったことがあれば、問題が発生する可能性があります。ソフトウェアの速度には、実際には配信速度とパフォーマンスの2種類があることがわかりました。顧客が最初に必要であり、開発者が次に必要であり、CTOが両方を必要とします。少なくともあなたが公開しようとしているときは、はるかに最初のものが最も重要です。2つ目は、計画して繰り返す必要があるものです。2つは通常互いに矛盾します。

おそらくもっと興味深いのは、Goで追加のパフォーマンスを実現し、低遅延システムでそれを実現できるようにするためのいくつかの方法を研究したことです。言語のデザインは非常にシンプルですが、時には代償があります。2つの高速間のトレードオフと同様に、コードのライフサイクルとコードのパフォーマンスの間にも同様のトレードオフがあります。スピードは、簡素化、開発時間、継続的なメンテナンスを犠牲にしてもたらされます。賢明な選択をしてください。

おすすめ

転載: blog.csdn.net/qq_32198277/article/details/102399339