Kotlin에서의 동시성 프로그래밍

JhoonP·2023년 5월 5일
0

동시성과 병렬성

동시성(Concurrency)와 병렬성(Parallelism)의 개념을 모르고 있는듯해서 먼저 알아보았습니다.

Concurrent Computing:

Concurrent computing is a form of computing in which several computations are executed concurrently—during overlapping time periods—instead of sequentially—with one completing before the next starts.

Parallelism Computing:

Parallel computing is a type of computation in which many calculations or processes are carried out simultaneously

얼핏보면 동시성, 병렬성 모두 동시에 다수의 코드가 같은 시간에 병렬로 실행되고 있어야하는 개념처럼 보이지만, 동시성은 다수의 코드가 하나의 시간에서 동시에 실행되며 순차적으로 실행되지 않는다면 동시성을 만족한 것으로 판단합니다.
따라서 싱글 코어에서 멀티 쓰레드 작업을 실행시킨다면 쓰레드는 하나씩 실행되겠지만, context switching이 일어나면서 각 쓰레드가 번갈아가면서 작업을 수행하기 때문에 비순차적 결과를 발생시켜 동시성을 만족하게 됩니다.

반대로 병렬성의 경우 다수의 연산이 동시에 발생해야 하기 때문에 멀티코어 환경에서 동작하는 멀티쓰레드는 병렬성을 만족시킬 수 있습니다.

Kotlin에서 동시성 프로그래밍

Kotlin에서는 Java에서 제공하는 Thread를 보다 가볍고 동시성 문제를 해결할 수 있는 방식으로 다룰 수 있도록 Coroutine을 제공합니다.

동시성 프로그래밍 코드를 작성하다면 필연적으로 쓰레드를 lock, unlock하면서 context switching을 수행하게 됩니다. 이런 과정에서 Coroutine은 Thread보다 효율적인 동작 방식을 보여줍니다.

먼저 Process, Thread, Coroutine의 차이를 알아봅시다.

위의 이미지에서 보듯이, Coroutine은 쓰레드 하나에 여러개 존재할 수 있습니다.

Thread는 lock되는 경우, 풀리기 전까지 동작을 중지합니다.
하지만 Coroutine의 경우 하나의 thread에 여러개가 들어갈 수 있기 때문에, thread의 특정 coroutine을 block하더라도 해당 thread에 있는 다른 coroutine을 실행시킴으로써 thread가 낭비되는 경우를 방지할 수 있습니다.

또한, Coroutine은 thread에 종속되지 않기 때문에, coroutine 작업이 일시 중단되더라도 쓰레드는 중단하지 않고 계속 실행될 수 있어 더 빠른 연산량을 보여줍니다.

coroutine을 생성하는 비용 또한 쓰레드 생성 비용보다 적기 때문에 같은 동시성 작업량에도 coroutine이 더 높은 효율을 보입니다.

동시성 문제 해결

하지만 동시성 프로그래밍은 언어에 구분 없이 동시성 문제(race condition, deadlock 등)가 발생할 수 있다.
이러한 문제를 해결하기 위한 기능으로 Kotlin은 synchronized를 제공합니다.

synchronized

synchronized는 임계구역 영역을 람다로 받고, 해당 람다에 단일 쓰레드만 접근할 수 있도록 lock, unlock을 수행하는 함수입니다.

class CounterSynchronized {
    var count = 0

    fun plusCount() {
        synchronized(this) {
            count++
        }
    }
}

fun synchronizedTest() {
    val counter1 = CounterSynchronized()

    val time = measureTimeMillis {
        val jobs = List(100) {
            Thread {
                repeat(10000) {
                    counter1.plusCount()
                }
            }.also { it.start() }
        }
        println("Thread in synchronizedTest: ${Thread.activeCount()}")
        jobs.forEach { it.join() }
    }
    println("Synchronized: Counter1 = ${counter1.count}. Time = $time")
}

위와 같이 다수의 coroutine이 하나의 자원에 동시접근하는 임계구역을 synchronized로 감싸주면 단일 쓰레드만 해당 구역에 접근할 수 있게됩니다.

하지만 synchronized의 경우 thread를 기반으로 동작하기 때문에 임계구역 내에서 다른 쓰레드의 완료를 대기하는 경우 스스로 block되거나, 다른 thread들이 unlock을 기다리는 경우도 그대로 block 돼버리기 때문에 낭비가 발생하는 문제가 있습니다.

하지만 앞에서 언급한 Coroutine을 사용하면 thread를 block시키는 것이 아닌 coroutine을 block 시킴으로써 thread는 그대로 다른 coroutine 작업을 진행하면서 쓰레드 낭비 문제를 해결할 수 있습니다.

Mutex

Mutex는 coroutine에서 동기화 프로그래밍을 위해 사용할 수 있는 도구입니다.

class CounterMutex {
    var count = 0

    fun plusCount() {
        count++
    }
}

fun mutexTest() = runBlocking {
    val counter1 = CounterMutex()

    val time = measureTimeMillis {
        coroutineScope {
            val mutex = Mutex()
            val jobs = List(100) {
                launch(Dispatchers.Default) {
                    mutex.withLock {
                        repeat(10000) {
                            counter1.plusCount()
                        }
                    }
                }
            }
            println("Thread in mutexTest: ${Thread.activeCount()}")
            jobs.forEach { it.join() }
        }
    }
    println("Mutex: Counter1 = ${counter1.count}, Time = $time")
}

위와 같이 mutex를 생성하고 임계구역을 withLock에 람다로 넣어주면 해당 구역에 들어간 coroutine의 작업이 끝나기 전까지 다른 coroutine은 접근할 수 없게 된다.

Thread in synchronizedTest: 102
Synchronized: Counter1 = 10000000. Time = 727
Thread in mutexTest: 14
Mutex: Counter1 = 10000000, Time = 40

Mutexsynchronized보다 낮은 쓰레드를 생성하고 연산시간 또한 빠른 것을 확인할 수 있다.
쓰레드는 임계구역을 만나는 순간 그대로 block되기 때문에 계속해서 새로운 쓰레드를 생성해서 쓰레드 갯수가 많아지고 빈번한 context switching을 수행하느라 계산량이 많아진다.
하지만 Mutex를 만난 coroutine은 coroutine이 대기할 뿐 속한 쓰레드는 다른 coroutine을 실행시키기 때문에 많은 쓰레드를 필요로 하지 않고 coroutine의 context switching이 쓰레드의 것보다 자원소모가 적기 때문에 연산시간도 더 빠른 것으로 추정된다.

마치며

주로 사용했던 프레임워크인 Unity, Flutter는 애초부터 단일 쓰레드가 기본 원칙(물론 멀티쓰레드 프로그래밍 API도 지원은 하지만)에 Kotlin 개발 당시에도 멀티쓰레드 프로그래밍은 잘 모른채로 도입한지라 이게 제대로 하고있는건가 싶었는데 이번 기회에 정리를 하면서 기존 업무 코드도 리팩토링을 해야겠지 싶다.
추가로 블로그나 Stackoverflow 등에서 synchronizedMutex를 설명, 비교하는 글들의 내용은 다 비슷비슷한데 출처가 없고 공식 docs에도 찾을 수 없는 말들이 많아 출처를 찾는데 상당히 힘들었던 것 같다.

참고

profile
배울게 끝이 없네 끝이 없어

0개의 댓글