Home [CS] 비동기 프로그래밍 (3) - 동시성 프로그래밍
Post
Cancel

[CS] 비동기 프로그래밍 (3) - 동시성 프로그래밍

Android에서 애플리케이션을 실행하여 생성된 프로세스는 메인 스레드를 가지게 됩니다. 그리고 일반적으로 이 스레드는 UI 스레드로서, Android UI와 관련된 구성 요소(android.view, android.widget 패키지)가 작동하는 스레드입니다. 즉 이 스레드에서 UI의 상태를 변경하고, 사용자의 입력과 같은 이벤트를 받습니다. 따라서 이 스레드가 Blocking; 대기 상태에 놓이면 UI가 업데이트 되지 않으며, 이벤트에 반응할 수 없기 때문에 사용자는 앱을 사용할 수 없게 되며 에러가 발생한 것으로 여기게 됩니다. UI 스레드가 5초 이상 차단되면 ANR(Application Not Responding) 대화상자가 표시됩니다.

따라서 Android 애플리케이션 개발에 있어서 네트워크, I/O 작업시에 가장 주의해야 할 것은 UI 스레드가 Blocking 되지 않도록 해야 한다는 것입니다. 이러한 작업들은 UI 스레드가 아닌 백그라운드 스레드에서 수행 되어야 합니다.

이와 반대로 UI 상태를 업데이트하거나, 이벤트에 반응하는 작업은 UI 스레드에서만 수행되어야 합니다. 백그라운드 스레드에서 UI의 상태를 업데이트하거나, 이벤트에 반응하는 작업을 수행할 수 없습니다. 하지만 Android 프레임워크에서 제공하는 몇몇 함수(Activity.runOnUiThread(Runnable), View.post(Runnable) 등)를 통해 백그라운드 스레드에서 UI 스레드에 접근할 수 있습니다.

이렇게 애플리케이션 개발에 여러 스레드를 사용하는 멀티스레드 프로그래밍과 I/O, 네트워크 작업, 연산이 오래걸리는 작업 등에 쓰이는 비동기 프로그래밍은 일반적인 개발에 비해 예외 처리나 함수 호출 시점에 있어 고려해야 할 사항이 더 많기 때문에 난이도가 높습니다.

멀티스레드, 비동기 프로그래밍에서 생길 수 있는 문제들을 살펴보고, 이들에 대한 해결책에 대해 살펴보겠습니다.

동시성 프로그래밍

동시성 프로그래밍으로 작성되지 않은 코드를 우선 생각해봅시다. 우리가 일반적으로 쉽게 떠올릴 수 있는 코드 대부분이 해당될텐데, 코드가 작성된 순서대로 실행됩니다. 하나의 스레드(수행 흐름)가 존재하는 것입니다.

동시성 프로그래밍은 여러 스레드(수행 흐름)가 존재하는 것입니다.

동시성과 병렬성

동시라는 표현은 처음 동시성 프로그래밍을 접하는 사람들에게 혼란을 줄 수 있다고 생각합니다. 동시라는 표현으로 인해 동시성 프로그래밍은 여러 수행 흐름(스레드)이 동시에 실행되는 것으로 인식됩니다.

물론 애플리케이션의 사용자 경험에는 차이가 없을 수 있지만 실제로 실행되는, CPU 자원을 점유하는 것은 동시가 아닐 수 있습니다. 특히 멀티코어 프로세서가 기본이 된 현재에서는 병렬성과 동시성의 차이점을 명확히 하는 것이 이해에 도움이 될 것이라고 생각합니다.

동시성

동시성은 두 개 이상의 스레드의 실행 시간이 겹쳐질 때 발생합니다.

하지만 이것이 두 개 이상의 스레드가 동시에, 병렬적으로 실행되고 있다는 의미는 아닙니다. CPU 스케줄러에서는 이 스레드들을 번갈아 가면서 할당하지만, 여러 스레드의 실행이 마쳐지지 않고 번갈아 가면서 동시에 실행되고 있다는 의미입니다.

병렬성

병렬성은 두 개 이상의 스레드가 같은 시간에 실행 중인 것을 의미합니다. 같은 시간에 하나의 CPU 코어에서는 하나의 스레드만 실행 가능합니다. 따라서 둘 이상의 스레드가 같은 시간에 실행 중이려면 적어도 두 개 이상의 CPU 코어가 존재해야(또는 분산 컴퓨팅) 할 것입니다.

병렬성을 가진다면 동시성을 가진다고 할 수 있지만, 동시성을 가진다고 해서 병렬성을 가진 것은 아닙니다.

그렇다면 병렬성이 동시성보다 항상 더 좋은, 상위 호환의 개념이 아닐까? 라고 생각할 수 있을 것입니다. 하지만 CPU 자원을 거의 필요로 하지 않는 작업이라면 여러 CPU 코어를 점유할 필요가 없을 것입니다.

따라서 스레드에서 실행하는 작업이 CPU의 자원을 계속 필요로 하는 작업인지, 아니면 I/O나 네트워크와 같은 외부 입출력 작업인지에 따라 나누어 생각하고, 수행 방법도 다르게 한다면 더 효율적일 것입니다.

CPU 바운드

CPU 바운드는 CPU의 자원을 주로 필요로 하는 작업입니다.

하지만 필요 이상으로 스레드를 생성하는 것 또한 오히려 성능을 저하시킬 수 있습니다. 예를 들어 코어가 하나인데 스레드가 10개일 경우, 각 스레드가 번갈아 가면서 실행될 것이고, 스레드의 생성, 실행되는 스레드의 교체 시에는 문맥 교환(Context Switching)을 위한 시간이 소요됩니다. 따라서 이 경우 오히려 스레드가 하나만 존재하고 하나의 코어에서 계속 실행되는 것이 오히려 좋은 방법일 수 있습니다.

물리적인 코어와 소프트웨어의 스레드의 실행 관계를 고려하였을 때, 멀티코어 프로세서에서 CPU 자원 활용도를 높이기 위해서는 코어의 수만큼 스레드가 존재하는 것이 일반적으로 적절할 것입니다.

I/O 바운드

I/O 바운드는 CPU의 자원은 거의 필요로 하지 않고 대부분의 시간을 외부의 입력을 기다리는 것에 사용하는 작업입니다.

즉, CPU 바운드의 작업에서는 멀티코어 프로세서의 성능을 최대한으로 활용하기 위해 여러 개의 스레드를 생성하고 작업을 나누어 수행하는 것이 중요하지만, I/O 바운드 작업은 CPU의 자원보다는 외부의 처리 결과를 반환받는 것이 중요하기 때문에 전체 실행 시간에 비해 CPU 자원을 필요로 하는 시간은 적습니다.

지난 포스트에서 이런 경우 해당 프로세스/스레드를 Blocking 상태로 만들고, 스케줄러에서 다른 프로세스/스레드를 CPU에 할당한다는 것을 알 수 있었습니다. 하지만 Blocking 상태에서는 결과를 반환받기 전까지 다시 CPU 자원을 할당받을 수 없기 때문에, 해당 프로세스/스레드를 사용할 수 없게 됩니다. 또한 간단한 I/O나, 많은 I/O 작업을 동시에 수행할 때 이를 모두 각각의 스레드로 나눈다면, 각 스레드의 전환 과정에서 문맥 교환으로 인한 오버헤드 또한 성능을 낮추는 요인이 될 것입니다.

SMP에서 발생할 수 있는 문제

앞선 포스트들에서도 대칭형 다중 프로세서(Symmetric Multi-Processor, SMP)에 대한 이야기를 다루었습니다. 현재 Android OS를 사용하는 모바일 기기들의 거의 대부분이 SMP 모델을 따르고 있기 때문입니다. 각 CPU가 메모리의 접근에 있어서 동등한 권한을 가지고 있다는 의미는 한 CPU가 접근한 메모리 주소에 대해 다른 CPU도 똑같이 접근할 수 있다는 의미입니다.

그렇기 때문에 SMP에서 작동하는 멀티스레드 프로그래밍에서는 여러 스레드가 각기 다른 CPU에서 동시에 접근할 수 있다는 가정을 하고 접근해야 아래에서 설명할 문제들을 방지할 수 있습니다.

이하 내용은 프로세스 대신 스레드로 작성되었는데, 실제로는 프로세스와 스레드 모두 가능합니다. 다만 Android 애플리케이션 개발에 있어서는 스레드 간의 경합 상태가 발생할 가능성이 높기 때문에 스레드로 표현하였습니다.

경합 상태

경합 상태(Race Condition)는 두 개 이상의 스레드에서 같은 데이터에 동시에 접근하고, 이 중 하나 이상의 스레드에서 데이터를 수정할 때 발생할 수 있는 현상입니다.

SMP에서는 메모리 영역을 모든 CPU 코어에서 접근할 수 있기 때문에(공유 자원) 서로 다른 스레드에서 같은 데이터에 접근할 수 있고, 동시성 프로그래밍에서는 이 순서를 예측하기 어렵습니다. 프로세스의 실행 결과는 일반적으로 항상 같아야 하지만 이렇게 실행 순서를 예측할 수 없게 되면 예측할 수 없는 상황이 발생할 수 있습니다.

예를 들어 var num = 10 변수에 접근해 10을 더하는 스레드가 두 개 존재하고, 각자 한 번씩 실행한다고 가정하면 일반적으로 10을 두 번 더해 num = 30이 되는 것을 기대할 수 있을 것입니다. 하지만 이는 각 스레드가 순차적으로 실행되었을 때에는 정상적으로 작동하지만, 동시에 실행된다면 두 스레드가 각각 num = 10을 확인하고 10을 더해 num = 20으로 값을 쓰게 될 수 있습니다.

또다른 예로 I/O 작업을 위해 공유 자원을 초기화 후 값을 가져온다고 가정했을 때, 작업이 미쳐 끝나지 않았음에도 불구하고 값을 다른 스레드에서 사용하는 경우 I/O 작업 스레드와 값을 사용하는 스레드의 실행의 실행 순서에 따라 성공 여부가 결정됩니다. 만약 I/O 작업 완료 이전에 값을 사용하는 스레드가 공유 자원에 접근한다면 에러가 발생할 것입니다.

이렇게 공유 자원에 접근하는 순서에 따라 다른 결과를 낼 수 있는 영역을 임계 구역(Critical Section) 이라고 합니다. 임계 구역에서 발생할 수 있는 문제를 해결하기 위해서는 동시성 프로그래밍이라도 결과를 보장하도록 일부 과정에 있어 순차적으로 작업이 진행될 필요가 있습니다.

교착 상태

교착 상태(Deadlock)는 프로세스/스레드의 자원 할당, 임계 구역 접근 시에 발생할 수 있습니다. 특정 프로세스/스레드가 자원을 점유한 상태로 이를 해제하지 않고, 다른 프로세스/스레드가 이 자원을 필요로 한다면, 점유한 자원을 해제해야 할 것입니다. 만약 두 프로세스/스레드가 각각 두 개의 자원을 필요로 하는데 하나씩만 가지고 있다면, 어떤 프로세스/스레드는 가지고 있던 자원을 양보하여 먼저 작업을 끝내게 할 수 있을 것입니다. 하지만 그렇지 못하고 서로 상대의 자원 해제만을 기다린다면 더 이상 작업을 진행할 수 없을 것입니다.

이러한 교착 상태가 발생하기 위해서는 아래의 네 가지 조건을 모두 만족해야 합니다.

발생 조건

상호 배제: 경합 상태를 해결하기 위한 방법이 오히려 교착 상태의 조건이 될 수 있기 때문에 락의 사용에 있어서는 주의가 필요합니다.

비선점: 특정 프로세스/스레드가 가진 자원을 다른 프로세스/스레드가 빼앗을 수 없습니다. 스스로 자원을 반납할 때까지 기다려야만 합니다.

점유 상태로 대기: 프로세스/스레드가 자원을 점유한 상태에서 다른 자원을 기다리고 있는 상황입니다. 이 때 이 프로세스/스레드가 점유한 자원은 다른 프로세스/스레드가 필요로 하는 자원일 수 있습니다.

원형 대기: 점유 상태로 대기하는 프로세스/스레드가 원형을 이루어야 합니다. 이 때 원형이라는 것은 서로 필요로 하는 자원과 보유를 하는 자원의 관계입니다.

해결법

위의 네 가지 중 하나만 해결되어도 교착 상태를 해결할 수 있기 때문에 하나씩 살펴보겠습니다.

우선 상호 배제의 경우는 락을 쓰지 않는다면 해결할 수 있을 것입니다. 하지만 락을 쓰는 경우는 대부분 경합 상태와 같이 문제가 발생할 수 있는 상황을 해결하기 위한 것이기 때문에 이 방법은 거의 쓰이지 않을 것입니다.

비선점의 경우 선점으로 바꾼다면 해결할 수 있을 것입니다. 선점형 CPU 스케줄링을 사용하거나, 스레드의 경우 우선 순위를 지원하는 프로그래밍 언어를 사용한다면 해결될 것입니다.

점유 상태로 대기의 경우 자원을 순차적으로 획득하는 것이 아닌, 모든 자원을 동시에 획득하도록 하는 방식을 사용한다면 해결될 수 있을 것입니다. 또는 락을 획득하는 순서에 있어 코드를 수정하여 해결할 수 있을 것입니다.

원형 대기의 경우에도 우선 순위를 가지게 하여 순환하지 않고 먼저 자원을 획득할 프로세스/스레드를 지정한다면 해결할 수 있을 것입니다.

해결을 위한 동기화 기법들

임계 구역에서 발생할 수 있는 문제들을 해결 하기 위해서는 세 가지 조건을 모두 만족해야 합니다.

상호 배제: 한 스레드가 임계 구역에 접근하면 다른 스레드는 접근할 수 없어야 합니다.

한정 대기: 스레드가 임계 구역 접근을 위해 무한정 기다려서는 안됩니다.

진행의 융통성: 한 스레드가 임계 구역에 접근하는 중이 아니라면 다른 스레드가 진행에 방해받지 않고 임계 구역에 접근할 수 있어야 합니다.

앞서 살펴본대로 임계 구역에서는 순차적으로 접근할 수 있도록 제한을 할 필요가 있습니다. 이를 위해서 임계 구역에 락을 설정할 수 있습니다. 특정 스레드가 임계 구역의 데이터를 사용하면 락을 건 후, 사용을 마칠 때 락을 다시 풀어 다른 스레드에서 접근할 수 있도록 하는 것입니다. 위의 세 가지 조건을 모두 만족할 수 있도록 락을 적절히 구현하여야 합니다.

이러한 락 기법을 Mutex(뮤텍스)라고 부르기도 합니다. 상호 배제(Mutual Exclusion)의 줄임말로, 위 세 가지 조건을 만족하기 위해 다양한 알고리즘이 고안되었습니다. 그 중에서 많이 쓰이는 기법에 대해 정리해보겠습니다.

뮤텍스

이를 구현하는 가장 간단한 방법으로 임계 구역에 접근하는 스레드가 Boolean 등의 변수를 통해 접근을 알린 후, 사용이 끝나면 다시 Boolean 값을 원래대로 돌려놓도록 하는 것입니다. 이 때 다른 스레드가 임계 구역에 접근할 때에는 먼저 Boolean 값을 확인하여 다른 스레드가 접근하는 중인지 알 수 있을 것입니다. 즉 Boolean 변수가 락의 역할을 하는 것입니다. 이해하기도 간단하고 구현하기도 쉽지만, 많은 문제점을 가지고 있습니다.

우선 경합 상태에서 예시로 들었던 동시 접근 문제가 여전히 발생할 수 있습니다. 한 스레드가 Boolean 값을 확인 후 실행은 했지만, 아직 Boolean 값을 바꾸지 못했는데 스케줄러에 의해 타임아웃이 발생해 다시 큐로 돌아가고, 다른 스레드가 Boolean 값을 확인하였을 때에는 락이 잠기지 않은 상태일 것입니다.

이를 개선하기 위한 다양한 알고리즘들이 나왔지만, 현재 많이 사용되고 있는 방법 중 대표적인 방법은 아래의 세마포어와 모니터입니다.

세마포어

Semaphore(세마포어)는 임계 구역에 진입할 수 있는 스레드의 수를 제한합니다. 뮤텍스와의 차이점은 뮤텍스를 구현하기 위한 다양한 알고리즘이 제시되었는데, 데이크스트라 알고리즘으로 기억에 남는 에츠허르 데이크스트라가 고안한 이 알고리즘은 다른 방식에 비해 간단합니다.

임계구역에 접근하려는 스레드는 wait()을 수행하여 현재 임계 구역에 접근할 수 있는지 세마포어에 묻습니다. 세마포어는 한 번에 접근 가능한 스레드의 수에 기반한 현재 접근 가능한 스레드의 카운터를 가지고 있어 접근이 가능하면 카운터를 하나 빼고 임계 구역에 접근합니다. 이미 카운터가 0이라면 세마포어는 임계 구역을 사용하려는 스레드의 큐에 스레드를 등록합니다. 임계 구역에 접근했던 스레드가 작업을 마치면 signal() 신호를 보냅니다. 그러면 세마포어에서는 저장되어 있던 대기 큐 중 하나를 빼서 임계 구역에 접근할 수 있도록 합니다.

세마포어에서 임계 구역에 접근하려는 스레드의 수를 1로 제한한다면, 뮤텍스처럼 사용할 수 있습니다.

Java의 Semaphore 클래스는 acquire()release()가 각각 wait()signal()의 역할을 합니다.

하지만 세마포어에서도 여전히 문제를 가지고 있습니다. 각 스레드가 wait()signal()을 적절한 때에 반드시 호출해주어야 하며, 순서가 바뀌거나 둘 중 하나라도 빠지면 세마포어 전체에 영향을 끼치게 될 것입니다. 카운터가 제대로 작동하지 않거나 계속 임계 구역을 점유하게 되어 여전히 교착 상태의 위험성을 가지고 있습니다.

모니터

모니터는 세마포어를 구현하면서 발생할 수 있는 에러의 가능성을 줄이기 위해 임계 구역을 직접적으로 스레드에서 접근하는 것 대신 스레드에 접근을 위한 인터페이스만 제공합니다.

다른 알고리즘에서 발생할 수 있는 문제에 대해 해결책들을 개선하였고 안전성도 높기 때문에 많은 프로그래밍 언어에서 사용되고 있습니다. 대표적으로 Java의 동기화 기능들인 ReentrantLock, synchronized 키워드 등은 모니터를 사용합니다.

정리

과거 Android 프레임워크에서는 비동기 프로그래밍을 돕는 AsyncTask 라는 클래스가 존재했지만 현재는 deprecated 되었습니다. 대신 Java의 java.util.concurrent의 API들을 사용하거나 Kotlin의 Coroutines 사용을 안내하고 있습니다.

Kotlin의 Coroutines는 Java의 java.util.concurrent의 기능들에 더해 스레드 상태를 Non-Blocking으로 유지하는 방법(일시 정지)에 대해 다양하면서 쉬운 해결책을 제시합니다. 또한 스레드의 실행 흐름을 더 작게 나누어 한 스레드에서 여러 실행 흐름을 유지할 수 있는 코루틴(한 스레드 내에서 동시성을 가지도록 수행 흐름을 나눔)을 지원합니다. 이에 대해서 다음 포스트에 다루도록 하겠습니다.

참고자료

This post is licensed under CC BY 4.0 by the author.

[CS] 비동기 프로그래밍 (2) - 리눅스의 프로세스, 스레드, 태스크

-