본 게시물은 Ivan Fytsyk님의 게시글 Asynchronous execution in Kotlin and Swift: Concurrency problems and how to solve them을 바탕으로 작성되었습니다.
Swift와 Kotlin, 각 언어에서 비동기 실행하기
본 게시글에서는 모바일 앱 개발의 양대 언어인 Swift와 Kotlin에서 비동기 실행을 하는 방법에 대해 정리합니다.
이전 글에서 우리는 비동기 실행의 기본적인 예를 살펴보았습니다. 여러분은 아마도 변경 가능한 공유 상태(shared mutable state)와 교착상태(dead-lock)라는 두 개의 일반적인 문제에 대해 이미 알고 있을 것입니다. 이러한 문제는 양대 언어에서도 공통된 문제이기에 서로 비슷한 해결책을 가지고 있습니다. 지금부터 살펴보겠습니다.
Shared Mutable State
Shared mutable state부터 시작해 보겠습니다. 아래 예에서 우리는 어떤 정수 값을 비동기식으로 1,000회 증가시켜보겠습니다.
import kotlinx.coroutines.*
import org.junit.Test
class ConcurrencyProblemTest {
private var someValue = 0
fun runCodeWithConcurrencyProblem() = runBlocking {
for (i in 0 .. 999) {
GlobalScope.launch() {
someValue = someValue + 1
}
}
delay(2000)
print(someValue)
}
}
import UIKit
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
var someValue = 0
for _ in 0 ... 999 {
DispatchQueue.global().async {
someValue = someValue + 1
}
}
sleep(2)
print(someValue)
예상했다시피 이 값은 1,000이 되지 못하고 때때로 달라집니다. 이러한 Shared Mutable State의 해결책은 간단합니다. 변경 가능한 변수의 읽기와 쓰기를 동기화시키면 됩니다. 즉 읽기와 쓰기 작업은 주로 실행되는 쓰레드에서 직렬로(serially) 실행되어야 합니다.
import kotlinx.coroutines.*
import org.junit.Test
class ReadingWritingSerialTest {
private var someValue = 0
val singleThreadContext = newSingleThreadContext("mySerialContext")
private fun incrementValue() {
GlobalScope.launch(singleThreadContext) {
someValue = someValue + 1
}
}
fun runCodeWithConcurrencyProblem() = runBlocking {
for (i in 0 .. 999) {
GlobalScope.launch() {
incrementValue()
}
}
delay(2000)
print(someValue) // result is always 1,000
}
}
import UIKit
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
var someValue = 0
let serialQueue = DispatchQueue(label: "mySerialQueue")
func incrementValue() {
serialQueue.async {
someValue = someValue + 1
}
}
for _ in 0 ... 999 {
DispatchQueue.global.sync {
incrementValue()
}
}
sleep(2)
print(someValue) // always prints 1,000
여기서 여러분은 우리가 작성한 비동기 코드를 동기 코드로 변환하였고, 이로 인해 멀티쓰레드 실행의 이점을 얻을 수 없게 됨을 알았을 것입니다. 맞습니다. 왜냐하면 mutable 변수의 값을 변경하는 것 외에 아무것도 하지 않았기 때문입니다. 하지만 실전 코드에서는, 백그라운드에서 실행될 필요가 있는 연산 및 그 결과는 동기 방식으로 보관하는 연산으로 이루어진 부분들이 많을 수 있습니다.
Dead-lock
두 번째 문제는 교착 상태(dead-lock)입니다. 일반적으로 어떤 코드 블록이 내부의 코드 블록에 기술된 내용이 종료될 때까지 기다리나, 내부의 코드 블록 또한 상위의 코드가 종료될 때까지 기다리고 있을 때 쿄착 상태는 발생합니다.
import kotlinx.coroutines.*
import org.junit.Test
class DeadLockTest {
val singleThreadContext = newSingleThreadContext("mySerialContext")
@Test
fun runCodeWithConcurrencyProblem() = runBlocking {
runDeadLock()
delay(2000)
}
fun runDeadLock() {
GlobalScope.launch(singleThreadContext) {
println("Blocking coroutine start")
runBlocking(singleThreadContext) {
// 바깥 블록은 안쪽 블록이 완료될 때까지 기다리겠으나,
// 안쪽 블록도 마찬가지로 바깥 블록이 완료될 때까지 기다립니다.
println("Unreachable statement")
}
println("Unreachable statement")
}
}
}
import UIKit
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
let serialQueue = DispatchQueue(label: "mySerialQueue")
serialQueue.sync {
print("Blocking block starts")
serialQueue.sync {
print("Unreavhable statement")
}
}
이들 예제에서는 하나의 싱글 쓰레드 큐와 두 블록의 코드가 사용되었습니다. 첫 번째 블록에서 우리는 동기식으로 두 번째 블록을 시작하였고 새로운 블록의 실행이 끝날 때까지 첫 번째 블록은 일시중지됩니다. 하지만 두 번째 블록도 실행되지 못할 것입니다. 왜냐하면 두 번째 블록은 작업 큐의 맨 마지막에 추가되었기 때문에 큐에서 앞쪽 순서에 놓인 블록이 종료될 때까지는 두 번째 블록도 큐에서 순서가 옮겨지지 못하기 때문입니다. 그러한 종류의 교착 상태를 피하기 위한 필자의 조언은 다음과 같습니다.
중첩된 비동기 코드 블록을 실행할 때 동일한 큐를 사용하지 마세요.
import kotlinx.coroutines.*
import org.junit.Test
class DeadLockResolutionTest {
val singleThreadContext1 = newSingleThreadContext("mySerialContext1")
val singleThreadContext2 = newSingleThreadContext("mySerialContext2")
@Test
fun runCodeWithConcurrencyProblem() = runBlocking {
runDeadlock()
delay(2000)
}
fun runDeadLock() {
GlobalScope.launch(singleThreadContext1) {
println("Blocking coroutine start")
runBlocking(singleThreadContext2) {
// 바깥 블록은 별도의 쓰레드에서 실행되고 있기에,
// 다음 문장은 접근이 가능합니다.
println("Unreachable statement")
}
println("Unreachable statement")
}
}
}
import UIKit
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
let serialQueue1 = DispatchQueue(label: "mySerialQueue1")
let serialQueue2 = DispatchQueue(label: "mySerialQueue2")
serialQueue1.sync {
print("Blocking block starts")
serialQueue2.sync {
print("Unreachable statement") // 이미 접근 가능하게 되었습니다.
}
print("Unreachable statement")
}
여기까지가 필자가 접해보았던 일반적으로 흔한 동시성 문제였습니다. 여러분도 보시다시피 동시성 문제와 그에 대한 해법은 양대 언어에서 매우 비슷하게 보입니다. 읽어주셔서 감사합니다.