Котлин сопрограммирует многопоточный диспетчер и потокобезопасность для локальных переменных

#multithreading #kotlin #kotlin-coroutines

Вопрос:

Давайте рассмотрим этот простой код с сопрограммами

 import kotlinx.coroutines.*
import java.util.concurrent.Executors

fun main() {
    runBlocking {
        launch (Executors.newFixedThreadPool(10).asCoroutineDispatcher()) {
            var x = 0
            val threads = mutableSetOf<Thread>()
            for (i in 0 until 100000) {
                x  
                threads.add(Thread.currentThread())
                yield()
            }
            println("Result: $x")
            println("Threads: $threads")
        }
    }
}
 

Насколько я понимаю, это вполне законный код сопрограммы, и он действительно дает ожидаемые результаты:

 Result: 100000
Threads: [Thread[pool-1-thread-1,5,main], Thread[pool-1-thread-2,5,main], Thread[pool-1-thread-3,5,main], Thread[pool-1-thread-4,5,main], Thread[pool-1-thread-5,5,main], Thread[pool-1-thread-6,5,main], Thread[pool-1-thread-7,5,main], Thread[pool-1-thread-8,5,main], Thread[pool-1-thread-9,5,main], Thread[pool-1-thread-10,5,main]]
 

Вопрос в том, что делает эти модификации локальных переменных потокобезопасными (или это потокобезопасно?). Я понимаю, что этот цикл на самом деле выполняется последовательно, но он может изменять запущенный поток на каждой итерации. Изменения, внесенные из потока на первой итерации, все равно должны быть видны потоку, который выбрал этот цикл на второй итерации. Какой код гарантирует такую видимость? Я попытался декомпилировать этот код на Java и покопаться в реализации сопрограмм с помощью отладчика, но не нашел подсказки.

Комментарии:

1. Да, этот код является законным, но только потому, что вы запускаете здесь одну сопрограмму. x и threads переменные не являются потокобезопасными, но доступ к ним не осуществляется одновременно, так что это нормально. Я не совсем понимаю, что вы имеете в виду, говоря о видимости изменений. Как вы отметили, этот код выполняется последовательно, так почему же изменения не будут видны?

2. Ах, вы, наверное, имели в виду, что x это не изменчиво?

3. Под видимостью я подразумеваю факт записи в переменную из одного потока, которая видна другому потоку, на самом деле концепция, используемая моделью памяти Java. Да, на мой взгляд x , изменчивость решила бы проблему, связанную с x тем, существовала ли вообще проблема (я все еще предполагаю, что с этим кодом нет проблем, но это отчасти противоречит моему пониманию параллелизма).

4. Кажется, это именно такой вопрос: github.com/Kotlin/kotlinx.coroutines/issues/1363 Окончательный ответ, на мой взгляд, все еще немного неясен, я не знаю наверняка. Но похоже, что в сопрограмме состояние всегда «в актуальном состоянии», независимо от того, какой поток изменял его последним. Так что в каком-то смысле он ведет себя как изменчивый. Но мне также было бы интересно получить точный ответ на этот вопрос, как и почему это работает.

5. ^ Я имел в виду не совсем первоначальный вопрос на Github (в котором говорится о 2 сопрограммах), но обсуждение ниже, в котором упоминается случай с одной сопрограммой

Ответ №1:

Ваш вопрос полностью аналогичен пониманию того, что ОС может приостановить поток в любой момент его выполнения и перенести его на другое ядро процессора. Это работает не потому, что рассматриваемый код является «многоядерным», а потому, что среда гарантирует, что один поток ведет себя в соответствии со своей семантикой программного порядка.

Среда выполнения сопрограмм Kotlin также гарантирует безопасность вашего последовательного кода. Вы должны запрограммировать эту гарантию, не беспокоясь о том, как она поддерживается.

Если вы хотите углубиться в детали «как» из любопытства, ответ будет «это зависит». Каждый диспетчер сопрограмм может выбрать свой собственный механизм для достижения этой цели.

В качестве поучительного примера мы можем сосредоточиться на конкретном диспетчере, который вы используете в своем опубликованном коде: JDK fixedThreadPoolExecutor . Вы можете отправлять произвольные задачи этому исполнителю, и он будет выполнять каждую из них в одном (произвольном) потоке, но многие задачи, отправленные вместе, будут выполняться параллельно в разных потоках.

Кроме того, служба исполнителя предоставляет гарантию того, что код, ведущий к выполнению , executor.execute(task) произойдет-до того, как код в задаче, а код в задаче произойдет-до того, как другой поток будет наблюдать за его завершением ( future.get() , future.isCompleted() , получение события от связанного CompletionService ).

Диспетчер сопрограмм Котлина управляет сопрограммой на протяжении всего жизненного цикла приостановки и возобновления, полагаясь на эти примитивы службы исполнителя, и, таким образом, вы получаете гарантию «последовательного выполнения» для всей сопрограммы. Одна задача, отправленная исполнителю, завершается всякий раз, когда сопрограмма приостанавливается, и диспетчер отправляет новую задачу, когда сопрограмма готова к возобновлению (когда вызывается пользовательский код continuation.resume(result) ).