Kotlin MultiplatformSeniorTechnical

Каковы ограничения Kotlin/Native в отношении coroutines и многопоточности?

Старый MM требовал заморозки объектов для передачи между потоками (freeze()), новый MM с Kotlin 1.7.20 снял это ограничение. Главные актуальные проблемы: suspend функции не экспортируются как Swift async/await (нужен SKIE), нет lifecycle-aware scope для iOS.

Эволюция модели памяти в Kotlin/Native

История coroutines и многопоточности в Kotlin/Native делится на два периода: до и после нового Memory Manager (New MM), включённого по умолчанию в Kotlin 1.7.20.

Старый Memory Manager (до 1.7.20): «freeze или ошибка»

В оригинальной модели памяти Kotlin/Native действовало строгое правило: объект может быть изменён только из одного потока. Попытка передать мутабельный объект в другой поток вызывала InvalidMutabilityException. Для передачи данных между потоками объект нужно было «заморозить» через freeze(), после чего он становился immutable навсегда.

Это делало coroutines крайне неудобными: Dispatchers.Default на Native использовал пул потоков, и любой мутабельный state в coroutine мог упасть с непонятной ошибкой.

Новый Memory Manager (Kotlin 1.7.20+)

Новый ММ принёс модель памяти, аналогичную JVM: объекты могут быть разделены между потоками без заморозки. Concurrent GC работает параллельно с приложением, что снижает паузы.

// shared/src/commonMain/kotlin/com/example/CoroutineExample.kt
import kotlinx.coroutines.*

class DataRepository {
    private val _data = mutableListOf<String>()

    // С новым MM это работает на Native без ошибок
    suspend fun loadData(): List<String> = withContext(Dispatchers.Default) {
        // Симуляция сетевого запроса
        delay(100)
        _data.add("item") // Мутация в background thread — OK в новом MM
        _data.toList()
    }

    fun processInBackground(block: suspend () -> Unit): Job {
        return CoroutineScope(Dispatchers.Default).launch {
            block()
        }
    }
}

Текущие ограничения coroutines на Kotlin/Native

1. Dispatchers.Main

На iOS Dispatchers.Main требует зависимости kotlinx-coroutines-core с Native таргетом. Без неё Dispatchers.Main не существует. Начиная с kotlinx.coroutines 1.7.0, Main dispatcher на iOS реализован через CFRunLoop.

// build.gradle.kts
commonMain.dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}
// Отдельная зависимость для iosMain больше не нужна — core включает Native реализацию

2. Интеграция со Swift async/await

Suspend функции не экспортируются нативно как Swift async функции. Из Swift они выглядят как функции с completionHandler:

// Kotlin
class UserService {
    suspend fun fetchUser(id: String): User {
        return api.getUser(id)
    }
}
// В Swift без дополнительных библиотек:
// userService.fetchUser(id: "123") { user, error in ... }
// Не идиоматично для Swift 5.5+

Для нормальной интеграции используют одно из двух решений:

  • SKIE (Swift Kotlin Interface Enhancer) от Touchlab: автоматически генерирует Swift async/await обёртки для suspend функций, sealed classes → Swift enums, Flow → AsyncSequence.
  • KMP-NativeCoroutines от rickclephas: аннотации + Gradle плагин, генерирует Swift-friendly API.
// С SKIE — достаточно обычного Kotlin кода
// SKIE автоматически генерирует:
// func fetchUser(id: String) async throws -> User

// build.gradle.kts
plugins {
    id("co.touchlab.skie") version "0.9.0"
}

3. Flow и Swift

kotlinx.coroutines Flow не имеет прямого Swift-эквивалента. SKIE конвертирует Flow в AsyncSequence:

// Kotlin
class WeatherRepository {
    fun temperatureUpdates(): Flow<Double> = flow {
        while (true) {
            emit(fetchTemperature())
            delay(5000)
        }
    }
}

// Swift с SKIE:
// for await temp in weatherRepository.temperatureUpdates() {
//     updateUI(temp)
// }

4. CoroutineScope и lifecycle на iOS

На Android есть viewModelScope и lifecycleScope. На iOS lifecycle управляется иначе — нет встроенной интеграции с UIViewController/SwiftUI View lifecycle. Нужно вручную отменять scope при deinit:

// Kotlin: base class для iOS ViewModels
open class BaseViewModel {
    protected val scope = CoroutineScope(
        SupervisorJob() + Dispatchers.Main
    )

    fun onCleared() {
        scope.cancel()
    }
}
// Swift:
class MyViewController: UIViewController {
    let viewModel = MyViewModel()

    deinit {
        viewModel.onCleared() // критично не забыть
    }
}

Подводные камни

  • runBlocking недоступен на Main thread iOS: вызов runBlocking на главном потоке iOS приводит к deadlock, потому что Main dispatcher использует тот же runloop. Используйте только в тестах или фоновых потоках.
  • Утечки памяти через retain cycles: circular references между Kotlin объектами и Swift closure не всегда обнаруживаются GC. Особенно опасны при передаче callbacks из Swift в Kotlin.
  • Старые статьи с .native-mt артефактом: до kotlinx.coroutines 1.6.0 для Native нужен был отдельный артефакт kotlinx-coroutines-core-native-mt. С 1.6.0 основной артефакт поддерживает Native с новым MM.
  • StateFlow и SwiftUI: StateFlow не интегрируется со SwiftUI @ObservableObject напрямую. Нужны обёртки (SKIE делает это автоматически через ObservableObject extension).
  • Dispatchers.IO на Native: Dispatchers.IO на iOS эмулируется через Dispatchers.Default (фиксированный пул потоков), а не через безлимитный пул как на JVM. Это важно для приложений с большим количеством параллельных I/O операций.
  • Exception propagation: непойманные исключения в coroutine на Native крашат приложение через terminate(), а не через UncaughtExceptionHandler как на JVM/Android. Всегда используйте CoroutineExceptionHandler в production-корутинах.
  • Structured concurrency и Objective-C callbacks: при интеграции с ObjC API (UIKit completion handlers) легко нарушить structured concurrency — callback приходит вне Kotlin coroutine context. Используйте suspendCancellableCoroutine для правильной интеграции.
  • SKIE vs KMP-NativeCoroutines: оба решения работают, но несовместимы друг с другом. Выбирайте одно на старте проекта — миграция болезненна.

Common mistakes

  • Объяснять «coroutines и многопоточность в Kotlin/Native» только как синтаксис и не описывать поведение runtime/compiler.
  • Игнорировать важный риск: Считать Native coroutines JVM-копией опасно: проблемы проявятся как гонки, frozen assumptions или ограничения interop.
  • Давать пример без edge case: отмены, null, recomposition, platform boundary или ошибки.

What the interviewer is testing

  • Формулирует суть темы «coroutines и многопоточность в Kotlin/Native» своими словами и связывает ее с кодом.
  • Называет механизм: На Native важно проверять актуальную memory model, main-thread UI constraints и доступность dispatcher-а для платформы.
  • Видит production-последствие: Считать Native coroutines JVM-копией опасно: проблемы проявятся как гонки, frozen assumptions или ограничения interop.

Sources

Related topics