Kotlin コルーチンの同時実行性の問題: 明らかにミューテックスでロックしましたが、なぜ機能しなかったのでしょうか?

序文

最近引き継いだプロジェクトで、上司が長い間放置されていたバグを私に送ってきて、それをチェックして修正するように頼まれました。

このプロジェクトの問題は、おそらく、特定のビジネスでデータベースにデータを挿入する必要があり、同じ種類のデータが 1 回だけ挿入されるようにする必要があるのに、データが繰り返し挿入されてしまうことです。

コードをクリックすると、最後に逃げた兄弟が非常に慎重にコードを書いていることがわかりました。繰り返しを判断するロジックは、層ごとにネストされています。最初に、ローカル データベースに繰り返しなしで 1 回クエリが実行され、その後、サーバーに再度クエリが要求されました。最後に、挿入する前にローカル データベースを再度クエリします。合計3層の判定ロジックを記述しました。しかし、なぜそれが繰り返されるのでしょうか?

よく見てみると、おお、当然ですがコルーチンによる非同期クエリが使用されていることが分かりました。

でも、いや、Mutex でロックしてませんでしたか? どうすればそれを繰り返すことができるのでしょうか?

ミューテックス何してるの?何をロックしましたか?あなたが守っているものを見てください。

現時点では、ミューテックスも私と同じで何も守ることができません。

しかし、本当に責任があるのは Mutex でしょうか? この記事では、コルーチンの並行性を実現するために Mutex を使用すると失敗につながる可能性があるという問題を簡単に分析し、正直な Mutex の不満を解消します。

前提知識: コルーチンと同時実行性について

マルチスレッド プログラムでは、たとえば次の典型的な例のように、同期の問題が発生する可能性があることはよく知られています。

fun main() {
    
    
    var count = 0

    runBlocking {
    
    
        repeat(1000) {
    
    
            launch(Dispatchers.IO) {
    
    
                count++
            }
        }
    }

    println(count)
}

上記のコードは何を出力すると思いますか?

分かりませんし、知る方法もありません、そうです。

なぜなら、上記のコードでは 1000 回ループし、毎回新しいコルーチンを開始してから、countコルーチン内で自己インクリメント操作を実行しているからです。

問題は、countこれらのコルーチンがいつ実行されるか分からず、これらのコルーチンの値がcount他のコルーチンによって変更されていないことも保証できないため、操作が同期していることを保証できないことです。実行中。

その結果、count値は不定になります。

もう 1 つのよく知られた事実は、kotlin のコルーチンは単純にスレッドのカプセル化として理解できるため、実際には異なるコルーチンが同じスレッドまたは異なるスレッドで実行される可能性があることです。

上記のコードに印刷スレッドを追加します。

fun main() {
    
    
    var count = 0

    runBlocking {
    
    
        repeat(1000) {
    
    
            launch(Dispatchers.IO) {
    
    
                println("Running on ${
      
      Thread.currentThread().name}")
                count++
            }
        }
    }

    println(count)
}

出力の一部をキャプチャします。

Running on DefaultDispatcher-worker-1
Running on DefaultDispatcher-worker-4
Running on DefaultDispatcher-worker-3
Running on DefaultDispatcher-worker-2
Running on DefaultDispatcher-worker-5
Running on DefaultDispatcher-worker-5
Running on DefaultDispatcher-worker-2
Running on DefaultDispatcher-worker-6
Running on DefaultDispatcher-worker-2
Running on DefaultDispatcher-worker-2
Running on DefaultDispatcher-worker-7
Running on DefaultDispatcher-worker-7
Running on DefaultDispatcher-worker-7
Running on DefaultDispatcher-worker-7

……

異なるコルーチンが異なるスレッドで実行されることも、同じスレッドを使用して異なるコルーチンが実行されることもあることがわかります。この機能により、コルーチンにはマルチスレッドの同時実行性の問題もあります。

では、同時実行性とは何でしょうか?

簡単に理解すると、同じ時間内に複数のタスクを実行することですがこの目的を達成するために、異なるタスクが分割され、分散して実行される場合があります。

これに対応して、並列処理の概念もあります。これは、単に複数のタスクが同じ時点で一緒に実行されることを意味します。

1.png

つまり、並列であろうと同時であろうと、同時に同じリソース上で動作する必要がある複数のスレッドが存在する可能性があるため、リソースの「競合」が発生します。このとき上記の例が表示されます 複数のスレッドが動作しているためcount、最終的なcountの値は1000未満になります これも分かりやすいです 例えばcountこの時は1です スレッド1で読み込まれた後、スレッド 1 はそれに対して +1 操作を実行し始めましたが、スレッド 1 が書き込みを完了する前にスレッド 2 が来て、それを読み取り、countそれが 1 であることがわかり、さらに +1 操作を実行しました。この時点では、どちらが先にスレッド 1 と 2 を書き終えても、count最終的には 2 になるだけですが、明らかに、ニーズに応じて 3 にする必要があります。

それならこれを解決するのは簡単です。スレッドが 1 つしかない限り、あまり多くのスレッドが存在しないようにしましょう。

実際、すべてのコルーチンが 1 つのスレッドのみで実行されるように指定します。

fun main() {
    
    
    // 创建一个单线程上下文,并作为启动调度器
    val dispatcher = newSingleThreadContext("singleThread")

    var count = 0

    runBlocking {
    
    
        repeat(1000) {
    
    
            // 这里也可以直接不指定调度器,这样就会使用默认的线程执行这个协程,换言之,都是在同一个线程执行
            launch(dispatcher) {
    
    
                println("Running on ${
      
      Thread.currentThread().name}")
                count++
            }
        }
    }

    println(count)
}

インターセプトの最終出力は次のとおりです。

……
Running on singleThread
Running on singleThread
Running on singleThread
Running on singleThread
Running on singleThread
Running on singleThread
Running on singleThread
Running on singleThread
Running on singleThread
Running on singleThread
Running on singleThread
1000

Process finished with exit code 0

出力結果は最終的には正しいことがわかりますcountが、なぜ私の記事にまだ問題があるのでしょうか?

はは、実は君は私に捕まったんだよ。

コルーチン (スレッド) を使用する目的は何ですか? 時間のかかるタスクを実行できるようにしたり、実行時間を短縮するために複数のタスクを同時に実行できるようにしたりするだけではないでしょうか。単一のスレッドを使用しているのに、何が意味があるのでしょうか?

結局のところ、ここで例示したコードはcountこの変数に対して。実際にはマルチスレッドを開く必要はありませんが、実際の作業ではそのような操作が複数あるはずです。ある変数が他の変数で占有されているため続行しないほうがよいでしょうか。スレッド?なくなった? その場でブロックして待つだけですか?明らかに非現実的です、目を覚ましてください、世界はそれだけではなくcount、私たちが処理するのを待っているデータがまだたくさんあります。したがって、マルチスレッドを使用する目的は、特定の変数 (リソース) が使用できない場合に、占有されていない他のリソースを処理できるようにして、合計の実行時間を短縮することです。

しかし、他のコードがある程度まで実行され、バイパスできず、占有されているリソースを使用しなければならない場合はどうなるでしょうか?

占有スレッドが空いているかどうかに関係なく、このリソースを直接取得して処理を続行しますか? これは、序文で説明した状況が発生するため、明らかに非現実的です。

したがって、占有されているリソースを使用する必要がある場合は、占有が解放されるまで現在のスレッドを一時停止する必要があります。

通常、Java でこの問題を解決するには 3 つの方法があります。

  1. 同期した
  2. アトミック整数
  3. リエントラントロック

ただし、コルーチンはノンブロッキングであるため、これらを kotlin のコルーチンで使用するのは適していません。コルーチンを「一時停止」する必要がある場合 (たとえば)、コルーチンは通常中断されており、中断されたコルーチンはブロックされませんdelay(1000)。この時点で、スレッドは他のタスクを実行できるように解放されます。

Java では、スレッドを一時停止する必要がある場合 (たとえばThread.sleep(1000))、通常、スレッドは直接ブロックされ、ブロックが終了するまでスレッドは制限されます。

kotlin では、軽量の同期ロックが提供されています: Mutex

ミューテックスとは

synchronizedMutex は、kotlin コルーチンで置き換えたり、 Java スレッドで使用されるクラスです。前の例の自己インクリメント コードのロックReentrantLockなど、複数のコルーチンによって同時に実行されるべきではないコードをロックするために使用されます。count同時に実行されるコルーチンは 1 つだけであるため、マルチスレッドによって発生するデータ変更の問題が回避されます。

lock()Mutex には、とという 2 つのコア メソッドがありunlock()、それぞれロックとロック解除に使用されます。

fun main() {
    
    
    var count = 0
    val mutex = Mutex()

    runBlocking {
    
    
        repeat(1000) {
    
    
            launch(Dispatchers.IO) {
    
    
                println("Running on ${
      
      Thread.currentThread().name}")
                mutex.lock()
                count++
                mutex.unlock()
            }
        }
    }

    println(count)
}

上記のコードの出力は次のようにインターセプトされます。

……
Running on DefaultDispatcher-worker-47
Running on DefaultDispatcher-worker-20
Running on DefaultDispatcher-worker-38
Running on DefaultDispatcher-worker-15
Running on DefaultDispatcher-worker-14
Running on DefaultDispatcher-worker-19
Running on DefaultDispatcher-worker-48
1000

Process finished with exit code 0

コルーチンは別のスレッドで実行されますが、それでも正しく変更できることがわかりますcount

これは、count値を変更するときに呼び出したためです。mutex.lock()この時点では、次のコード ブロックは現在のコルーチンによってのみ実行が許可されることが保証されています。呼び出しのロックが解除されるまで、mutex.unlock()他のコルーチンはこのコード ブロックの実行を続行できます。

Mutex のlock原理は、 を呼び出すときに、ロックが他のコルーチンによって保持されていない場合は、ロックを保持して次のコードを実行し、ロックがすでに他のコルーチンによって保持されている場合は、現在のコルーチンのプロセスが開始されると単純に理解できますunlocklockロックが解放され、ロックが取得されるまでサスペンド状態になります。一時停止すると、そのスレッドが含まれているスレッドはブロックされませんが、他のタスクを実行できます。詳細な原理については参考資料 2 を参照してください。

実際に使用する場合は、ロック後に実行するコードで例外が発生した場合、保持したロックが解放されずデッドロックが発生するため、コルーチンはロックの解放を待つことがないため、 と を直接使用することは一般的ではありませんlock()unlock()それは永久に停止されます:

fun main() {
    
    
    var count = 0
    val mutex = Mutex()

    runBlocking {
    
    
        repeat(1000) {
    
    
            launch(Dispatchers.IO) {
    
    
                try {
    
    
                    mutex.lock()
                    println("Running on ${
      
      Thread.currentThread().name}")
                    count++
                    count / 0
                    mutex.unlock()
                } catch (tr: Throwable) {
    
    
                    println(tr)
                }
            }
        }
    }

    println(count)
}

上記のコードは次のように出力します。

Running on DefaultDispatcher-worker-1
java.lang.ArithmeticException: / by zero

また、プログラムは実行を継続し、終了することはできません。

実際、この問題を解決するのは非常に簡単で、finally実行が成功したかどうかに関係なくコードがロックを解放するようにコードを追加するだけです。

fun main() {
    
    
    var count = 0
    val mutex = Mutex()

    runBlocking {
    
    
        repeat(1000) {
    
    
            launch(Dispatchers.IO) {
    
    
                try {
    
    
                    mutex.lock()
                    println("Running on ${
      
      Thread.currentThread().name}")
                    count++
                    count / 0
                    mutex.unlock()
                } catch (tr: Throwable) {
    
    
                    println(tr)
                } finally {
    
    
                    mutex.unlock()
                }
            }
        }
    }

    println(count)
}

上記のコードの出力は次のようにインターセプトされます。

……

Running on DefaultDispatcher-worker-45
java.lang.ArithmeticException: / by zero
Running on DefaultDispatcher-worker-63
java.lang.ArithmeticException: / by zero
Running on DefaultDispatcher-worker-63
java.lang.ArithmeticException: / by zero
Running on DefaultDispatcher-worker-63
java.lang.ArithmeticException: / by zero
1000

Process finished with exit code 0

各コルーチンはエラーを報告しますが、プログラムは実行でき、完全には中断されないことがわかります。

実際、ここでは Mutex の拡張関数を直接使用できますwithLock

fun main() {
    
    
    var count = 0
    val mutex = Mutex()

    runBlocking {
    
    
        repeat(1000) {
    
    
            launch(Dispatchers.IO) {
    
    
                mutex.withLock {
    
    
                    try {
    
    
                        println("Running on ${
      
      Thread.currentThread().name}")
                        count++
                        count / 0
                    } catch (tr: Throwable) {
    
    
                        println(tr)
                    }
                }
            }
        }
    }

    println(count)
}

上記のコードの出力は次のようにインターセプトされます。

……
Running on DefaultDispatcher-worker-31
java.lang.ArithmeticException: / by zero
Running on DefaultDispatcher-worker-31
java.lang.ArithmeticException: / by zero
Running on DefaultDispatcher-worker-51
java.lang.ArithmeticException: / by zero
Running on DefaultDispatcher-worker-51
java.lang.ArithmeticException: / by zero
Running on DefaultDispatcher-worker-51
java.lang.ArithmeticException: / by zero
1000

withLockを使用した後は、ロックとロック解除を自分で処理する必要はなく、一度だけ実行する必要があるコードをパラメータの高階関数に入れるだけでよいことがわかります

withLockソースコードを見てみましょう。

public suspend inline fun <T> Mutex.withLock(owner: Any? = null, action: () -> T): T {
    
    
    // ……

    lock(owner)
    try {
    
    
        return action()
    } finally {
    
    
        unlock(owner)
    }
}

action実際、これも非常に簡単です。つまり、渡した関数を実行する前に呼び出し、lock()実行後にfinally呼び出しますunlock()

ここまで言って、読者は「あなたはここで長い間話してきましたが、本題からそれたことはありませんか?」と尋ねたいかもしれません。あなたのタイトルはどこにありますか?なぜそれを言わないのですか?

心配しないでください、心配しないでください、それは来ませんか?

mutex.withLock を使用しても役に立たないのはなぜですか?

タイトルと序文のシーンに戻りますが、mutex.Unlockプロジェクト内で明らかに使用されている重複チェックコードがロックされているのはなぜですか、それとも繰り返し挿入されるのでしょうか。

お急ぎだと思いますが、心配しないでください。別の例をお見せしましょう。

fun main() {
    
    
    var count = 0
    val mutex = Mutex()

    runBlocking {
    
    
        mutex.withLock {
    
    
            repeat(10000) {
    
    
                launch(Dispatchers.IO) {
    
    
                    count++
                }
            }
        }
    }

    println(count)
}

このコードは 10000 を出力できると思いますか? 別のコードを見てください。

fun main() {
    
    
    var count = 0
    val mutex = Mutex()

    runBlocking {
    
    
        mutex.withLock {
    
    
            repeat(100) {
    
    
                launch(Dispatchers.IO) {
    
    
                    repeat(100) {
    
    
                        launch(Dispatchers.IO) {
    
    
                            count++
                        }
                    }
                }
            }
        }
    }

    println(count)
}

この段落はどうでしょうか?10000 を出力できると思いますか?

実際、少し考えてみれば10000を出力するのは明らかに不可能です。

一番上に追加しましたがmutex.lockWithただし、その中で多くの新しいコルーチンを開いたので、実際には、このロックは何も追加されていないことに等しいことを意味します。

mutex.lockWith上で見たソースコードを覚えていますか?

これは、単にlock新しいコルーチンを起動することと同じであり、簡単ですunlockが、実際にロックする必要があるコードは、新しく開始されたコルーチン内のコードである必要があります。

したがって、ロックするときはロックの粒度をできるだけ減らし、必要なコードのみをロックする必要があります。

fun main() {
    
    
    var count = 0
    val mutex = Mutex()

    runBlocking {
    
    
        repeat(100) {
    
    
            launch(Dispatchers.IO) {
    
    
                repeat(100) {
    
    
                    launch(Dispatchers.IO) {
    
    
                        mutex.withLock {
    
    
                            count++
                        }
                    }
                }
            }
        }
    }

    println(count)
}

ここで、ロックする必要があるのは実際にはcountに対する操作なので、ロック コードを に追加してcount++コードを実行し、完全に 10000 を出力するだけです。

上記の前提を踏まえて、私が引き継いだプロジェクトの簡略化されたコードのプロトタイプを見てみましょう。

fun main() {
    
    
    val mutex = Mutex()
    
    runBlocking {
    
     
        mutex.withLock {
    
    
        	// 模拟同时调用了很多次插入函数
            insertData("1")
            insertData("1")
            insertData("1")
            insertData("1")
            insertData("1")
        }
    }
}

fun insertData(data: String) {
    
    
    CoroutineScope(Dispatchers.IO).launch {
    
    
        // 这里写一些无关数据的业务逻辑
        // xxxxxxx
        
        // 这里进行查重 查重结果 couldInsert
        if (couldInsert) {
    
    
            launch(Dispatchers.IO) {
    
     
                // 这里将数据插入数据库
            }
        }
    }
}

この時点でデータベースが何回挿入されると思いますか1?

答えは明らかに予測できません。1 回、2 回、3 回、4 回、5 回の可能性があります。

このコードを作成するときのこの友人の精神的な旅を推測してみましょう。


产品:这里的插入数据需要注意一个类型只让插入一个数据啊

开发:好嘞,这还不简单,我在插入前加个查重就行了

提测后

测试:开发兄弟,你这里有问题啊,这个数据可以被重复插入啊

开发:哦?我看看,哦,这里查询数据库用了协程异步执行,那不就是并发问题吗?我搜搜看 kotlin 的协程这么解决并发,哦,用 mutex 啊,那简单啊。

于是开发一顿操作,直接在调用查重和插入数据的最上级函数中加了个 mutex.withlock 将整个处理逻辑全部上锁。并且觉得这样就万无一失了,高枕无忧了,末了还不忘给 kotlin 点个赞,加锁居然这么方便,不像 java 还得自己写一堆处理代码。

では、この問題を解決するにはどうすればよいでしょうか? 実際、最良の解決策は、特定のデータベース操作に合わせてロックの粒度を調整できる必要がありますが、上で述べたことを思い出してください。このプロジェクトにはクエリ コードの層が次々とネストされており、ロック コードを挿入するのは明らかに簡単ではありません。ロックを挿入することで山全体が直接崩壊することは避けたいのです。

launchしたがって、私の選択は、新しいコルーチンが追加される各場所に大量のロックを追加することです...

この山は私のせいで高くなりました、ははははは。

したがって、実際にはミューテックスの問題ではなく、ミューテックスを使用する人だけが問題になります。

参考文献

  1. Kotlin コルーチン - いくつかのソリューションと同時セキュリティのパフォーマンス比較
  2. Kotlin コルーチンの同時実行性の問題の解決策
  3. コルーチンの同時同期 Mutex アクター
  4. 同時実行性と並列処理を 1 つの記事で読む
  5. 共有可変状態と同時実行性

おすすめ

転載: blog.csdn.net/sinat_17133389/article/details/130894330