Problemas de concurrencia en las rutinas de Kotlin: obviamente lo bloqueé con un mutex, ¿por qué no funcionó?

prefacio

En un proyecto del que me hice cargo recientemente, el supervisor me envió un error que se había dejado durante mucho tiempo y me pidió que lo revisara y lo arreglara.

El problema del proyecto es probablemente que en un determinado negocio, es necesario insertar datos en la base de datos, y es necesario asegurarse de que el mismo tipo de datos solo se inserte una vez, pero ahora los datos se insertan repetidamente.

Hice clic en el código y vi que el último hermano que se escapó lo escribió con mucho cuidado. La lógica de juzgar la repetición se anidó capa por capa. Primero, se consultó la base de datos local una vez sin repetición, y luego se solicitó al servidor que consultara de nuevo. Por último, en la consulta de la base de datos local de nuevo antes de insertar. Se escribieron un total de tres capas de lógica de juicio. Pero ¿por qué se repite?

Eche un vistazo más de cerca, oh, resulta que se usa la consulta asíncrona con coroutine, no es de extrañar.

Pero, no, ¿no lo bloqueaste con Mutex? ¿Cómo se puede repetir?

Mutex, ¿qué estás haciendo? ¿Qué bloqueaste? Mira lo que estás custodiando.

En este momento, el Mutex es como yo, incapaz de proteger nada.

¿Pero es Mutex realmente el culpable? En este artículo, analizaremos brevemente el problema de que el uso de Mutex para realizar la concurrencia de corrutinas puede provocar fallas y aclarar las quejas de nuestro honesto Mutex.

Conocimientos previos: sobre corrutinas y concurrencia.

Es bien sabido que con los programas multihilo pueden surgir problemas de sincronización, por ejemplo, el siguiente ejemplo clásico:

fun main() {
    
    
    var count = 0

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

    println(count)
}

¿Qué crees que generará el código anterior?

No lo sé, y no tengo forma de saberlo, sí, lo hace.

Porque en el código anterior, hacemos un bucle 1000 veces, comenzamos una nueva corrutina cada vez y luego realizamos una countoperación de autoincremento en la corrutina.

El problema es que no podemos garantizar que countlas operaciones sean sincrónicas, porque no sabemos cuándo se ejecutarán estas corrutinas, y no podemos garantizar que los valores de estas corrutinas countno hayan sido modificados por otras corrutinas. durante la ejecución.

Como resultado, countel valor terminará siendo indefinido.

Otro hecho bien conocido es que las corrutinas en kotlin pueden entenderse simplemente como la encapsulación de subprocesos, por lo que, de hecho, diferentes corrutinas pueden ejecutarse en el mismo subproceso o en diferentes subprocesos.

Agregamos un hilo de impresión al código anterior:

fun main() {
    
    
    var count = 0

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

    println(count)
}

Capture parte de la salida:

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

……

Se puede ver que se pueden ejecutar diferentes corrutinas en diferentes subprocesos, o se puede usar el mismo subproceso para ejecutar diferentes corrutinas. Debido a esta característica, las corrutinas también tienen problemas de concurrencia de subprocesos múltiples.

Entonces, ¿qué es la concurrencia ?

Simplemente entiéndalo, es ejecutar múltiples tareas dentro del mismo período de tiempo.En este momento, para lograr este propósito, se pueden dividir y ejecutar diferentes tareas intercaladas.

En consecuencia, también existe un concepto de paralelismo , que simplemente significa que varias tareas se ejecutan juntas en el mismo momento :

1.png

En resumen, ya sea en paralelo o concurrente, implicará una "competencia" por los recursos, porque al mismo tiempo puede haber varios subprocesos que necesitan operar en el mismo recurso. En este momento, aparecerá el ejemplo anterior. Dado que varios subprocesos están operando en count, el countvalor final de será inferior a 1000, lo que también es fácil de entender. Por ejemplo, counten este momento es 1. Después de ser leído por el subproceso 1 , el subproceso 1 comenzó a realizar la operación +1 en él, pero antes de que el subproceso 1 terminara de escribir, vino el subproceso 2, también lo leyó y countdescubrió que era 1, y también realizó la operación +1 en él. En este punto, no importa quién termine de escribir primero el hilo 1 o el 2, countal final solo será el 2. Obviamente, según nuestras necesidades, deberíamos querer que sea el 3.

Entonces es facil solucionar esto, no dejemos que haya tantos hilos, siempre y cuando solo haya un hilo?

De hecho, especificamos que todas las rutinas se ejecutan en un solo hilo:

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

    var count = 0

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

    println(count)
}

El resultado final de la intercepción es el siguiente:

……
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

Se puede ver que el countresultado de la salida finalmente es correcto, entonces, ¿por qué todavía hay un problema con mi artículo?

Jaja, de hecho, fuiste atrapado por mí.

¿Cuál es el propósito de nuestro uso de coroutines (hilos)? ¿No es solo poder ejecutar tareas que consumen mucho tiempo o permitir que se ejecuten varias tareas al mismo tiempo para reducir el tiempo de ejecución? Ahora que está usando un solo hilo, ¿cuál es el punto?

Después de todo, el código que ejemplificamos aquí solo opera counten esta variable. Realmente no hay necesidad de abrir subprocesos múltiples, pero debe haber más de una operación de este tipo en el trabajo real. ¿No deberíamos continuar porque una determinada variable está ocupada por otra? hilos ? ¿Simplemente bloquear en su lugar y esperar? Obviamente poco realista, despierta, el mundo no es solo count, todavía hay muchos datos esperando que los procesemos. Entonces, el propósito de usar subprocesos múltiples es poder procesar otros recursos desocupados cuando una determinada variable (recurso) no está disponible, acortando así el tiempo total de ejecución.

Sin embargo, ¿qué sucede si se ejecutan otros códigos hasta cierto punto y no se pueden eludir y deben usar los recursos ocupados?

Independientemente de si el subproceso de ocupación está desocupado o no, ¿tomar directamente este recurso y continuar procesando? Obviamente poco realista, porque esto hará que suceda la situación descrita en nuestro prefacio.

Entonces, si nos encontramos con la necesidad de usar los recursos ocupados, debemos suspender el hilo actual hasta que se libere la ocupación.

Por lo general, hay tres formas de resolver este problema en Java:

  1. sincronizado
  2. Entero atómico
  3. Bloqueo de reentrada

Pero no es adecuado usarlos en la corrutina de kotlin, porque la corrutina no bloquea, cuando necesitamos que la corrutina se "pause" (por ejemplo), la corrutina generalmente está suspendida, delay(1000)y la corrutina suspendida no lo está. Bloqueará el subproceso donde se encuentra, y en este momento el subproceso se puede liberar para realizar otras tareas.

En Java, cuando es necesario suspender un subproceso (por ejemplo Thread.sleep(1000)), el subproceso generalmente se bloquea directamente y el subproceso se restringirá hasta que finalice el bloqueo.

En kotlin, se proporciona un bloqueo de sincronización ligero: Mutex

¿Qué es Mutex?

Mutex es una clase que se usa en las corrutinas de kotlin para reemplazar synchronizedo en los hilos de Java ReentrantLock. Se usa para bloquear código que no debe ser ejecutado por varias corrutinas al mismo tiempo, como countbloquear el código de autoincremento en el ejemplo anterior, para que se puede garantizar En el mismo momento, solo se ejecutará una rutina, evitando así los problemas de modificación de datos causados ​​​​por los subprocesos múltiples.

Mutex tiene dos métodos principales: lock()y unlock(), que se utilizan para bloquear y desbloquear respectivamente:

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)
}

La salida del código anterior se intercepta de la siguiente manera:

……
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

Se puede ver que aunque la corrutina se ejecuta en diferentes subprocesos, aún puede countmodificarse correctamente.

Esto se debe a que countllamamos al modificar el valor. mutex.lock()En este momento, se garantiza que el siguiente bloque de código solo puede ser ejecutado por la corrutina actual. Hasta que la llamada se mutex.unlock()desbloquee, otras corrutinas pueden continuar ejecutando este bloque de código.

lockEl principio y de Mutex unlockpuede entenderse simplemente como, al llamar lock, si el bloqueo no está en manos de otras corrutinas, mantenga el bloqueo y ejecute el siguiente código; si el bloqueo ya está en manos de otras corrutinas, la corrutina actual El proceso entra el estado suspendido hasta que se libera el bloqueo y se adquiere el bloqueo. Cuando se suspende, el subproceso en el que se encuentra no se bloqueará, pero puede realizar otras tareas. El principio detallado se puede encontrar en la Referencia 2.

En el uso real, generalmente no usamos lock()y unlock()directamente, porque si ocurre una excepción en el código ejecutado después del bloqueo, el bloqueo retenido nunca se liberará, lo que provocará un punto muerto. La rutina nunca esperará a que se libere el bloqueo, por lo que será suspendido para siempre:

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)
}

El código anterior genera:

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

Y el programa continuará ejecutándose y no se puede terminar.

De hecho, es muy simple resolver este problema, solo necesitamos agregarlo finallypara que el código debe liberar el bloqueo sin importar si se ejecuta con éxito o no:

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)
}

La salida del código anterior se intercepta de la siguiente manera:

……

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

Se puede ver que aunque cada corrutina reporta un error, el programa se puede ejecutar y no se suspenderá por completo.

De hecho, aquí podemos usar directamente la función de extensión de 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)
}

La salida del código anterior se intercepta de la siguiente manera:

……
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

Se puede ver que withLockdespués de usar , no necesitamos manejar el bloqueo y el desbloqueo por nosotros mismos, solo necesitamos colocar el código que debe ejecutarse solo una vez en la función de orden superior en sus parámetros.

Aquí hay un vistazo al withLockcódigo fuente:

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

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

De hecho, también es muy simple, es decir, llamar actionantes de ejecutar la función que le pasamos y llamar lock()después de la ejecución .finallyunlock()

Habiendo dicho tanto, los lectores pueden querer preguntar, ha estado hablando aquí durante mucho tiempo, ¿se ha desviado del tema? ¿Dónde está tu título? ¿Por qué no lo dices?

No te preocupes, no te preocupes, ¿no viene?

¿Por qué es inútil para mí usar mutex.withLock?

Volviendo a nuestro título y la escena del prefacio, ¿por qué mutex.Unlockel código de verificación de duplicación está bloqueado después de haber sido utilizado claramente en el proyecto, o habrá inserciones repetidas?

Sé que tienes prisa, pero no te preocupes, déjame mostrarte otro ejemplo:

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

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

    println(count)
}

¿Puedes adivinar que este código puede generar 10000? Mira otra pieza de código:

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

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

    println(count)
}

¿Qué pasa con este párrafo? ¿Puedes adivinar que puede generar 10000?

De hecho, mientras lo pensemos por un tiempo, obviamente es imposible generar 10000.

Aunque lo añadimos en la parte superior mutex.lockWith. Sin embargo, hemos abierto muchas rutinas nuevas en él, lo que significa que, de hecho, este bloqueo es igual a ninguna adición.

¿Recuerdas mutex.lockWithel código fuente que vimos arriba?

Esto es equivalente a simplemente locklanzar una nueva corrutina, lo cual es sencillo unlock, pero el código que realmente debe bloquearse debe ser el código en la corrutina recién iniciada.

Por lo tanto, debemos reducir la granularidad del bloqueo tanto como sea posible al bloquear, y solo bloquear el código requerido:

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

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

    println(count)
}

Aquí, lo que necesitamos bloquear es en realidad countla operación en , por lo que solo necesitamos agregar el código de bloqueo count++, ejecutar el código y generar 10000 perfectamente.

Con el presagio anterior, echemos un vistazo al prototipo de código simplificado del proyecto del que me hice cargo:

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) {
    
     
                // 这里将数据插入数据库
            }
        }
    }
}

¿Adivine cuántas veces se insertará la base de datos en este momento 1?

La respuesta es obviamente impredecible, una, dos, tres, cuatro, cinco veces son posibles.

Vamos a adivinar, el viaje mental de este amigo al escribir este código:


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

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

提测后

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

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

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

Entonces, ¿cómo soluciono este problema? De hecho, la mejor solución debería ser capaz de refinar la granularidad de bloqueo para operaciones específicas de la base de datos, pero recuerde lo que dije anteriormente, este proyecto ha anidado capa tras capa de código de consulta, obviamente no es fácil insertar el código de bloqueo. No quiero causar directamente que toda la montaña se derrumbe debido a la inserción de un candado en ella.

Entonces, mi elección es launchagregar un montón de candados a cada lugar donde se agrega una nueva rutina...

Esta montaña se ha vuelto más alta por mi culpa, jajajajaja.

Por lo tanto, en realidad no es un problema con el mutex, sino solo con las personas que lo usan.

Referencias

  1. Kotlin coroutine: varias soluciones y comparación de rendimiento de seguridad concurrente
  2. Soluciones a problemas de concurrencia en Kotlin Coroutines
  3. Sincronización concurrente de Coroutine Mutex Actor
  4. Lea Concurrencia y paralelismo en un artículo
  5. Estado mutable compartido y concurrencia

Supongo que te gusta

Origin blog.csdn.net/sinat_17133389/article/details/130894330
Recomendado
Clasificación