멀티스레딩은 안드로이드 개발자와 애증의 관계가 아닐까..?
안드로이드는 기본적으로 멀티스레딩 환경이기 때문에 스레드 관련 이슈가 많은데 그중 Race Condition이 발생한 상황에서 Mutex로 해결한 예를 코드를 통해 정리해 보려고 한다.
레이스 컨디션은 두 개 이상의 스레드가 동시에 공유 자원에 접근하고, 그 접근 순서에 따라 결과가 달라질 때 발생하는 문제
원인으로는 여러 스레드가 동기화되지 않은 상태에서 동일한 자원(예: 변수, 메모리)에 동시에 읽기 및 쓰기 작업을 수행할 때 발생하고 스레드들이 자원을 조정하지 않고 사용하려고 할 때 데이터 불일치나 예상치 못한 결과가 생길 수 있다.
데드락은 두 개 이상의 스레드가 서로 자원의 잠금을 기다리면서 무한 대기 상태에 빠지는 상황을 의미한다. 이로 인해 해당 스레드들은 진행하지 못하고 멈춘 상태
원인으로는 두 개 이상의 스레드가 서로 잠근 자원을 해제하지 않은 상태에서 상대방의 자원을 기다릴 때 발생하고 교착 상태는 일반적으로 잠금을 걸고 해제하는 순서가 일관되지 않을 때 발생한다.
멀티스레드 환경에서 여러 스레드가 동시에 접근할 때 문제가 발생할 수 있는 공유 자원에 접근하는 코드 블록을 의미
이 코드 블록은 동시에 하나의 스레드만 접근할 수 있어야 하며, 이를 통해 데이터 무결성과 일관성을 보장할 수 있다.
공유 자원 접근: 임계영역은 변수, 데이터 구조, 파일 등 여러 스레드가 동시에 접근하면 문제가 될 수 있는 공유 자원에 접근하거나 수정하는 코드
상호 배제: 임계영역 내에서는 한 번에 하나의 스레드만 접근할 수 있도록 해야 합니다. 이를 위해 동기화 메커니즘(예: Mutex, synchronized 키워드 등)을 사용하여 스레드들이 동시에 진입하지 못하도록 막는다.
데이터 일관성 보장: 적절한 동기화가 없으면 레이스 컨디션(race condition)과 같은 문제가 발생할 수 있으며, 이는 공유 자원의 불일치 상태를 초래할 수 있다.
멀티스레드 환경에서는 두 개 이상의 스레드가 동일한 자원에 동시에 접근하여 읽기 및 쓰기 작업을 수행할 수 있다. 이때 동기화되지 않은 코드가 실행되면 다음과 같은 문제가 발생할 수 있다
레이스 컨디션: 여러 스레드가 임계영역 내의 작업 순서에 따라 서로 다른 결과가 나올 수 있다.
데이터 불일치: 공유 자원에 대해 읽기/쓰기 작업이 중복되면 값이 예상대로 유지되지 않을 수 있다.
멀티스레드 환경에서 공유 자원에 대한 접근을 동기화하여 데이터의 무결성을 보장하는 동기화 메커니즘
Mutex는 한 번에 하나의 스레드만 특정 코드 블록(임계 영역)에 진입할 수 있도록 하여 동시에 여러 스레드가 동일한 자원을 수정할 때 발생할 수 있는 문제를 방지한다.
단일 스레드가 자원을 독점적으로 사용해야 할 때 사용된다.
하나의 스레드가 lock을 하면 다른 스레드는 자원이 unlock될 때까지 대기한다.
재진입 여부에 따라 reentrant mutex(재진입 가능)와 일반 mutex(재진입 불가능)로 나뉜다.
Semaphore는 카운터로 작동하며, 지정된 수의 스레드가 동시에 자원에 접근할 수 있게 허용한다. 또한 특정 스레드에 의해 소유되지 않으며, 임의의 스레드가 자원을 반환할 수 있다.
특정 개수의 스레드가 자원을 동시에 사용할 수 있도록 제한할 때 사용된다.
특징:
Counting Semaphore: 카운터 값이 0보다 큰 경우, 그만큼의 스레드가 동시에 자원을 사용할 수 있다.
Binary Semaphore: Mutex와 비슷하게 동작하며, 카운터 값이 1인 세마포어
Mutex와의 차이점으로는 세마포어는 지정된 수(1~n)개의 스레드가 동시에 자원에 접근할 수 있다는 점이 있다.
우선 Mutex의 구현체를 보면 세마포어를 상속 받지만 permits을 1, acquiredPermits을 lock이면 1 unlock이면 0으로 고정해 놓은 것을 볼 수 있다.
이제 문제와 해결 방안을 코드로 살펴보면
private var result: String? = null
@Test
fun main(): Unit = runBlocking {
launch { thread1() }
launch { thread2() }
}
private fun thread1() {
val currentThreadName = Thread.currentThread().name
println("$currentThreadName: thread1 start")
result = "Success"
println("$currentThreadName: thread1 end")
}
private fun thread2() {
val currentThreadName = Thread.currentThread().name
println("$currentThreadName: thread2 start")
println("$currentThreadName: 결과는?: $result")
println("$currentThreadName: thread2 end")
}
결과
Test worker @coroutine#2: thread1 start
Test worker @coroutine#2: thread1 end
Test worker @coroutine#3: thread2 start
Test worker @coroutine#3: 결과는?: Success
Test worker @coroutine#3: thread2 end
위와 같이 다른 스레드지만 딜레이가 없어 먼저 실행된 thread1이 먼저 끝나 thread2에서 result 값을 정상적으로 확인이 가능하다. 하지만
@Test
fun main(): Unit = runBlocking {
launch { thread1() }
launch { thread2() }
}
private suspend fun thread1() {
val currentThreadName = Thread.currentThread().name
println("$currentThreadName: thread1 start")
delay(3000L)
result = "Success"
println("$currentThreadName: thread1 end")
}
private suspend fun thread2() {
val currentThreadName = Thread.currentThread().name
println("$currentThreadName: thread2 start")
delay(1000L)
println("$currentThreadName: 결과는?: $result")
println("$currentThreadName: thread2 end")
}
결과
Test worker @coroutine#2: thread1 start
Test worker @coroutine#3: thread2 start
Test worker @coroutine#3: 결과는?: null
Test worker @coroutine#3: thread2 end
Test worker @coroutine#2: thread1 end
위와 같이 thread1는 3초 thread2는 1초가 걸린다면 result에 값이 초기화 되기 전에 읽으려고 하기 때문에 result값이 null인 것을 확인할 수 있다.
즉 공유자원인 result를 여러 스레드에서 사용할 때 동기화 이슈를 해결해야 한다.
여러 해결 방안이 있지만 Mutex를 사용하여 해결해 보면
private val mutex = Mutex()
private var result: String? = null
@Test
fun main(): Unit = runBlocking {
launch { thread1() }
launch { thread2() }
}
private suspend fun thread1() = mutex.withLock {
val currentThreadName = Thread.currentThread().name
println("$currentThreadName: thread1 start")
delay(3000L)
result = "Success"
println("$currentThreadName: thread1 end")
}
private suspend fun thread2() = mutex.withLock {
val currentThreadName = Thread.currentThread().name
println("$currentThreadName: thread2 start")
delay(1000L)
println("$currentThreadName: 결과는?: $result")
println("$currentThreadName: thread2 end")
}
결과
Test worker @coroutine#2: thread1 start
Test worker @coroutine#2: thread1 end
Test worker @coroutine#3: thread2 start
Test worker @coroutine#3: 결과는?: Success
Test worker @coroutine#3: thread2 end
위와 같이 동기화를 보장해 줄 수 있다.
결과를 살펴보면 thread1이 withLock
함수를 통해 임계 영역으로 들어가면 해당 mutex가 unlock이 되기 전엔 thread2가 해당 mutex를 선점할 수 없기 때문에 동기화가 보장이 된다.
위는 withLock
함수의 내부인데 살펴보면 코드 블럭이 실행 되기 전에 lock이 되며 끝나면 unlock이 되는 걸 확인 할 수 있다.
또한 withLock
함수의 owner는 기본적으로 코틀린에서 뮤텍스가 비재진입을 가능하도록 보장해 준다.
private val owner = Any()
private val mutex = Mutex()
private var result: String? = null
@Test
fun main(): Unit = runBlocking {
launch { thread1() }
launch { thread2() }
}
private suspend fun thread1() = mutex.withLock(owner) {
val currentThreadName = Thread.currentThread().name
println("$currentThreadName: thread1 start")
delay(3000L)
result = "Success"
println("$currentThreadName: thread1 end")
}
private suspend fun thread2() = mutex.withLock(owner) {
val currentThreadName = Thread.currentThread().name
println("$currentThreadName: thread2 start")
delay(1000L)
println("$currentThreadName: 결과는?: $result")
println("$currentThreadName: thread2 end")
}
결과
위의 코드 결과를 보면 에러가 발생하는 걸 볼 수 있다.
이는 thread1이 owner를 통해 이미 해당 뮤텍스를 소유하고 있는데 thread2가 해당 뮤텍스에 재진입하려고 했기 때문에 위와 같은 에러가 발생한다.
재진입이 가능한 lock을 사용하려면 ReentrantLock
(Java의 재진입 락)을 사용하면 된다.
멀티 스레딩에서 동기화 이슈는 언제봐도 머리가 아프다...