Как KMP обрабатывает параллелизм на iOS (с учётом отсутствия разделяемого изменяемого состояния)?
KMP на iOS использует Dispatchers.Main через RunLoop главного потока и Dispatchers.Default через GCD. Для thread-safe состояния применяют Mutex и atomicfu; suspend-функции экспортируются в Swift через SKIE или ручные callback-обёртки.
Параллелизм KMP на iOS: от старой memory model к новой
До Kotlin 1.7.20 Kotlin/Native использовал strict memory model с концепцией ownership: объект принадлежал одному потоку, передача между потоками требовала freeze(). С Kotlin 1.7.20 введена новая memory model (по умолчанию с 1.9), совместимая с JVM-семантикой. Однако iOS runtime накладывает собственные ограничения: UI можно обновлять только с main thread, а NSObject не является thread-safe.
Dispatchers на iOS
Kotlin/Native реализует Dispatchers.Main через RunLoop главного потока iOS. Dispatchers.Default использует пул потоков через GCD (libdispatch). Без явного указания dispatcher coroutine выполняется в том потоке, откуда запущена.
// commonMain — suspend-функция в репозитории
class NewsRepository(
private val api: NewsApi,
private val cache: NewsCache
) {
// suspend-функция безопасна для вызова с любого dispatcher
suspend fun getNews(category: String): List<Article> =
withContext(Dispatchers.Default) { // IO-работа на background thread
val cached = cache.get(category)
if (cached != null) return@withContext cached
val remote = api.fetchNews(category)
cache.put(category, remote)
remote
}
}
// commonMain — ViewModel с явными dispatcher-ами
class NewsViewModel(
private val repo: NewsRepository,
private val scope: CoroutineScope
) {
private val _articles = MutableStateFlow<List<Article>>(emptyList())
val articles: StateFlow<List<Article>> = _articles.asStateFlow()
fun loadNews(category: String) {
scope.launch(Dispatchers.Main) { // UI-обновление на main thread
val news = repo.getNews(category) // переключается внутри
_articles.value = news // StateFlow.value обновляется на Main
}
}
}
Экспорт coroutines в Swift
Swift не умеет вызывать suspend-функции напрямую. Два основных подхода: обёртка через callback или использование библиотеки KMP-NativeCoroutines / SKIE.
// Вариант 1: ручная обёртка через callback
// iosMain
fun NewsViewModel.loadNewsCallback(
category: String,
onSuccess: (List<Article>) -> Unit,
onError: (Throwable) -> Unit
) {
scope.launch {
try {
val news = newsRepo.getNews(category)
withContext(Dispatchers.Main) { onSuccess(news) }
} catch (e: Exception) {
withContext(Dispatchers.Main) { onError(e) }
}
}
}
// Вариант 2: SKIE — автоматически генерирует Swift async/await
// build.gradle.kts
plugins {
id("co.touchlab.skie") version "0.9.3"
}
// После этого Kotlin suspend-функции экспортируются как Swift async
// Swift-код:
// let news = try await viewModel.getNews(category: "tech")
Управление shared mutable state
Новая memory model позволяет обращаться к объектам из разных потоков, но race conditions по-прежнему возможны. Для thread-safe доступа используйте Mutex из kotlinx.coroutines или атомарные операции из kotlinx-atomicfu.
// commonMain — thread-safe кэш через Mutex
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class TokenStore {
private val mutex = Mutex()
private var token: String? = null
suspend fun getToken(): String? = mutex.withLock { token }
suspend fun setToken(value: String?) = mutex.withLock {
token = value
}
}
// AtomicFu для счётчиков без Mutex
import kotlinx.atomicfu.atomic
class RequestCounter {
private val count = atomic(0)
fun increment() = count.incrementAndGet()
fun get() = count.value
}
Отмена coroutines и lifecycle iOS
В iOS нет ViewModel с автоматической отменой scope. Scope нужно создавать и отменять вручную, привязывая к lifecycle ViewController или SwiftUI View.
// commonMain — scope factory
object CoroutineScopeFactory {
fun createMainScope(): CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.Main)
}
// Swift — управление lifecycle
class NewsViewController: UIViewController {
private let scope = CoroutineScopeFactory.shared.createMainScope()
private let viewModel: NewsViewModel
init() {
self.viewModel = NewsViewModel(
repo: NewsRepository(),
scope: scope
)
super.init(nibName: nil, bundle: nil)
}
deinit {
// Отменяем все coroutines при уничтожении VC
scope.cancel()
}
}
Подводные камни
- Вызов
runBlockingс main thread iOS приводит к deadlock: iOS RunLoop и coroutine dispatcher конкурируют за main thread. - Без явного
scope.cancel()вdeinitcoroutines продолжают работать после уничтожения ViewController — утечка памяти и ghost-обновления UI. - StateFlow собирается в Swift через
.watch { }(из KMP-NativeCoroutines) — без отмены подписки Flow не завершается и держит ViewController в памяти. - Старые библиотеки, не обновлённые под новую memory model, могут бросать
InvalidMutabilityExceptionдаже в Kotlin 1.9+. Dispatchers.IOнедоступен в Kotlin/Native — используйтеDispatchers.Defaultдля фоновых операций на iOS.- Исключения из Kotlin coroutines не автоматически конвертируются в Swift Error — нужен
@Throwsаннотация или обёртка через Result. - Thread Sanitizer в Xcode может показывать ложные срабатывания для Kotlin/Native кода — проверяйте через Unit-тесты в commonTest.
- Если CoroutineScope создан без
SupervisorJob, падение одной дочерней coroutine отменяет весь scope — на iOS это убьёт все активные запросы.
Common mistakes
- Объяснять «параллелизм на iOS» только как синтаксис и не описывать поведение runtime/compiler.
- Игнорировать важный риск: Если не зафиксировать ownership состояния, баги проявятся как гонки, зависания UI или разные результаты на simulator/device.
- Давать пример без edge case: отмены, null, recomposition, platform boundary или ошибки.
What the interviewer is testing
- Формулирует суть темы «параллелизм на iOS» своими словами и связывает ее с кодом.
- Называет механизм: Shared mutable state лучше ограничивать immutable моделями, state holders и explicit synchronization через coroutines primitives.
- Видит production-последствие: Если не зафиксировать ownership состояния, баги проявятся как гонки, зависания UI или разные результаты на simulator/device.