본문 바로가기

Android

[Android] Coroutine에서는 왜 Thread보다 교착상태가 덜 발생할까?

안드로이드 개발을 하며 비동기 처리를 할 때는 주로 Thread보다 Coroutine을 많이 사용하는데요,

왜 Coroutine에서는 Thread보다 교착 상태가 덜 발생할까요?

 

 

 

🔎 교착 상태란 (Deadlock)

 

교착 상태와 관련해서 이런 비슷한 이미지를 한 번쯤은 보셨을 것 같은데요,

위 이미지처럼 교착상태는 여러 스레드 또는 작업이 서로의 자원을 기다리며 무한 대기하는 상황을 뜻합니다

 

이를 잘 이해하기 위해서 먼저 스레드와 코루틴이 공유 자원을 어떻게 다루는지 살펴보도록 하겠습니다

 

 

 

👀 스레드(Thread)에서 공유 자원 보호

멀티 스레드 환경에서는 여러 스레드가 동시에 같은 자원 (ex. 변수, 파일, 네트워크 연결 등)에 접근할 수 있습니다

이런 경우 데이터가 꼬이거나 충돌하는 것을 방지하기 위해 뮤텍스(Mutext)나 세마포어(Semaphore) 같은 동기화 기법을 사용합니다

 

 

1. 뮤텍스 (Mutex, Mutual Exclusion)

  • 한 번에 한 개의 스레드만 공유 자원에 접근할 수 있도록 함
  • 스레드가 뮤텍스를 획득하면 다른 스레드는 해제될 때까지 대기해야 함
val mutex = Mutex()

suspend fun criticalSection() {
    mutex.lock()   // 락을 걸어서 다른 스레드/코루틴이 못 들어오게 함
    try {
        println("중요한 작업 수행 중...")
        delay(1000) // 중요 작업 처리
    } finally {
        mutex.unlock() // 락을 해제해서 다른 스레드/코루틴이 접근할 수 있도록 함
    }
}

 

위 코드와 같은 방식으로 스레드에 접근 가능한 공유 자원을 제한합니다

그렇지만 뮤텍스는 "단일 자원"에 대한 동기화를 제공할 뿐이지 무조건 교착 상태를 막아주는 것은 아닙니다

그렇기 때문에 잘못 사용하면 교착 상태가 발생할 수 있습니다 💦

 

❌ 뮤텍스에서 교착 상태가 발생하는 경우

val lock1 = Mutex()
val lock2 = Mutex()

suspend fun task1() {
    lock1.lock()
    println("Task 1: lock1 획득")
    
    delay(100)
    
    lock2.lock() // 여기서 교착 상태 발생 가능
    println("Task 1: lock2 획득")
    
    lock2.unlock()
    lock1.unlock()
}

suspend fun task2() {
    lock2.lock()
    println("Task 2: lock2 획득")
    
    delay(100)
    
    lock1.lock() // 여기서 교착 상태 발생 가능
    println("Task 2: lock1 획득")
    
    lock1.unlock()
    lock2.unlock()
}

fun main() = runBlocking {
    launch { task1() }
    launch { task2() }
}

 

예를 들어, 위와 같은 코드에서

`task1()` 이 lock1을 먼저 잡고 lock2를 기다립니다

그리고 `task2()` 는 lock2를 먼저 잡고 lock1을 기다립니다

이때 서로 lock을 풀지 못하고 무한 대기 상태(Deadlock)에 빠지게 됩니다

 

뮤텍스는 한 번에 하나의 자원만 보호하는 도구이기 때문에 여러 개의 락을 순서 없이 걸면 교착 상태가 발생할 수 있습니다

그렇기 때문에 뮤텍스 사용 시 교착 상태를 방지하기 위해서는

  1. 락 획득 순서를 항상 일정하게 유지하기
  2. `tryLock()` 을 사용해서 락을 못 잡으면 빠르게 포기하기

등의 규칙을 따라 안전하게 사용해야 합니다

 

 

2. 세마포어 (Semaphore)

  • 특정 개수만큼의 스레드가 공유 자원에 접근하도록 제한하는 동기화 도구
  • `permits` 값 (허용 가능한 스레드 수)을 1로 설정하면 뮤텍스처럼 동작
  • 예를 들어 데이터베이스 연결 개수를 3까지만 허용하는 등의 방식으로 사용됨
val semaphoreA = Semaphore(1)
val semaphoreB = Semaphore(1)

suspend fun safeTask1() {
    semaphoreA.acquire() // 항상 semaphoreA → semaphoreB 순서로 락을 잡음
    semaphoreB.acquire()
    println("Task 1: 모든 세마포어 획득")

    delay(100)

    semaphoreB.release()
    semaphoreA.release()
}

suspend fun safeTask2() {
    semaphoreA.acquire() // 항상 semaphoreA → semaphoreB 순서로 락을 잡음
    semaphoreB.acquire()
    println("Task 2: 모든 세마포어 획득")

    delay(100)

    semaphoreB.release()
    semaphoreA.release()
}

fun main() = runBlocking {
    launch { safeTask1() }
    launch { safeTask2() }
}

뮤텍스와 마찬가지로 하나의 스레드에 동시 접근을 막아 교착 상태가 일어나지 않도록 합니다

 

뮤텍스와의 차이점은 뮤텍스는 단일 스레드만 접근이 가능하지만 세마포어는 여러 개의 스레드가 동시에 접근이 가능하므로 일부 스레드는 동시에 실행될 수 있습니다

그렇기 때문에 뮤텍스는 단일 리소스를 보호할 때 주로 사용되고, 세마포어는 제한된 개수의 리소스를 관리할 때 사용합니다 (ex. DB 연결 풀, 네트워크 요청 제한 등)

 

그렇지만 세마포어 또한 잘못 사용시 교착 상태가 발생할 수 있습니다

 

❌ 교착 상태가 발생하는 경우

val semaphore1 = Semaphore(1) // 최대 1개 스레드만 접근 가능
val semaphore2 = Semaphore(1) // 최대 1개 스레드만 접근 가능

suspend fun task1() {
    semaphore1.acquire() // Task 1이 semaphore1을 먼저 얻음
    println("Task 1: semaphore1 획득")

    delay(100) // 잠깐 대기 (Task 2가 실행될 가능성 높음)

    semaphore2.acquire() // Task 1이 semaphore2를 기다림 (Task 2가 점유했을 가능성 있음)
    println("Task 1: semaphore2 획득")

    semaphore2.release()
    semaphore1.release()
}

suspend fun task2() {
    semaphore2.acquire() // Task 2가 semaphore2를 먼저 얻음
    println("Task 2: semaphore2 획득")

    delay(100) // Task 1이 실행될 가능성 높음

    semaphore1.acquire() // Task 2가 semaphore1을 기다림 (Task 1이 점유했을 가능성 있음)
    println("Task 2: semaphore1 획득")

    semaphore1.release()
    semaphore2.release()
}

fun main() = runBlocking {
    launch { task1() }
    launch { task2() }
}

 

위 코드에서는 `task1()` 이 semaphore1을 먼저 얻고, semaphore2를 기다립니다

그리고 `task2()` 가 semaphore2를 먼저 얻고, semaphore1을 기다립니다

이렇게 되면 서로가 상대방이 가진 세마포어를 기다리면서 영원히 멈추고 교착 상태가 발생하게 됩니다

 

그렇기 때문에 세마포어 또한 교착 상태를 방지하기 위해서 몇 가지 규칙이 필요합니다

  1. 항상 같은 순서로 세마포어 요청
  2. `tryAcquire()` 사용하기 - `acquire()` 는 무조건 기다리지만, `tryAcquire()` 는 락을 못 잡으면 바로 포기해서 교착 상태를 방지
  3. 타임아웃 설정 (`withTimeout`) - 일정 시간 동안 세마포어를 얻지 못하면 타임아웃을 발생시켜 교착 상태를 방지

 

 

 

👀 코루틴(Coroutine)에서의 공유 자원 보호

코루틴에서는 공유 자원 접근 시 동기화 도구를 잘 사용하지 않습니다

그 이유는

  1. 코루틴은 기본적으로 하나의 스레드에서 실행되어 뮤텍스를 사용할 필요가 적음
  2. 공유 자원 접근 시 Mutex를 사용하기 보다는 `withContext(Dispatchers.IO)` 와 같은 컨텍스트 전환을 사용

조금 더 자세하게 알아보자면

 

1. 왜 코루틴에서는 Mutex를 잘 사용하지 않을까?

코루틴은 스레드 풀을 사용하지 않고 기본적으로 하나의 스레드 내에서 비동기적으로 실행되므로, 일반적인 스레드에서의 동기화 도구인 Mutex나 Semaphore 같은 도구를 자주 사용할 필요가 없습니다

 

각 코루틴은 `suspend` 상태로 중단될 수 있기 때문에, 한 코루틴이 자원에 접근하고 있을 때 다른 코루틴은 대기할 수 있습니다

그렇기 때문에 동기화 도구를 사용하지 않아도 자원에 대한 동시 접근이 자연스럽게 조정되어 코루틴에서는 Mutex를 자주 사용하지 않습니다

 

 

2. `withContext(Dispatchers.IO)` 와 같은 컨텍스트 전환

`withContext` 와 같은 컨텍스트 전환을 사용하면, IO 작업이나 백그라운드 작업을 다른 스레드에서 처리하도록 보낼 수 있습니다

이 방법은 공유 자원 접근 시 동기화를 처리할 필요 없이, 작업이 별도의 스레드에서 실행되도록 하여 교착 상태나 경합 상태를 피할 수 있습니다

 

var sharedCounter = 0 // 공유 자원 (카운터)

suspend fun increment() {
    withContext(Dispatchers.IO) {  // IO Dispatcher로 컨텍스트 전환
        sharedCounter++
        println("sharedCounter: $sharedCounter")
    }
}

fun main() = runBlocking {
    // 여러 코루틴에서 공유 자원에 접근
    coroutineScope {
        repeat(5) {
            launch {
                increment()
            }
        }
    }
}

 

위 코드에서 `withContext(Dispatchers.IO)` 를 사용하여, IO 관련 작업을 백그라운드 스레드에서 처리합니다

컨텍스트 전환을 통해 백그라운드 스레드에서 실행함으로써 동기화 도구 없이도 동시성 문제를 피할 수 있습니다

 

사실 코루틴에서의 공유 자원 보호 방식을 보면 왜 교착 상태가 덜 발생하는지 알 것 같지만..!

마지막으로 왜 코루틴에서는 교착 상태가 덜 발생하는지 정리해보겠습니다

 

 

 

🤓 왜 코루틴에서는 교착 상태가 덜 발생할까?

1. 코루틴은 협력적 멀티태스킹(Cooperative Multitasking)을 사용

일반적으로 스레드 기반 멀티태스킹은 운영체제가 강제로 스케줄링하여 실행 순서를 제어합니다

이로 인해 스레드는 언제든지 중단될 수 있고, 이를 잘못 관리하면 교착 상태가 발생할 수 있습니다

예를 들어, 하나의 스레드가 리소스를 기다리며 중단되고, 그 리소스를 기다리는 또 다른 스레드가 중단되면 상호 대기 상태에 빠져 교착 상태가 일어날 수 있습니다

 

하지만 코루틴은 개발자가 명시적으로 `suspend` 를 호출하여 중단할 수 있습니다

즉, 코루틴은 자발적으로 실행 흐름을 제어할 수 있기 때문에 스레드처럼 강제적으로 중단되거나 다른 스레드에 의해 불필요하게 교착 상태에 빠지는 일이 적습니다

 

2. 비동기 방식으로 동작

suspend 함수는 스레드를 차단하지 않으면서 중단하고 다른 작업을 수행할 수 있습니다

이 방식은 차단된 상태에서 대기하는 시간이 없기 때문에 다른 코루틴이 먼저 실행될 수 있어 교착 상태를 피할 수 있습니다

 

3. 코루틴 빌더가 자동으로 데드락 방지

코루틴은 `launch` , `async` , `withContext` 등의 코루틴 빌더를 사용해서 실행합니다

`withContext(Dispatchers.IO)`, `launch(Dispatchers.Default)` 등을 사용하면 운영체제가 적절한 스레드에서 실행되도록 조절해 줍니다

이렇게 자동으로 스레드와 작업을 분배해주기 때문에 개발자가 특정 스레드에서만 실행해야 하는 문제나 스레드에 의한 데드락 상태를 피할 수 있게 됩니다

 

 

 

🍀 마무리

안드로이드 개발을 하면서 자연스럽게 Thread보다 Coroutine을 많이 사용하게 되면서 교착 상태가 잘 발생하지 않았는데요,

이렇게 정리를 하면서 새삼 다시 차이점을 알게 되었습니다

 

단순히 편리해서 사용하는 것보다 왜 더 편리하고 어떤 차이점이 있는지를 알고 사용하면 기술을 더 잘 사용할 수 있는 것 같습니다 :)

 

코루틴의 교착 상태에 대해 한번 생각해 볼 수 있던 기회 !