동기화란?
동기화를 쉽게 말하면 하나의 기준에 두 가지 이상의 것들이 서로 맞춰지거나 일치되는 상태를 말합니다. 따라서 시계를 맞추거나 노래를 부를 때, 음을 맞추고 리듬을 맞추는 것 또한 동기화의 일종 입니다.
그렇다면 컴퓨터 분야에서 동기화란?
여러 개의 스레드나 프로세스가 동시에 공유 자원에 접근하는 경우, 문제가 발생하지 않도록 하는 작업입니다. 동기화를 통해 공유 자원에 대한 접근을 제어하면서, 경쟁 상태(Race Condition)와 데드락(Deadlock) 같은 문제를 예방할 수 있습니다. 대표적인 동기화 기법으로는 뮤텍스(Mutex), 세마포어(Semaphore), 모니터(Monitor)가 있습니다.
크게 프로세스 동기화는 실행 순서 제어를 위한 동기화, 상호배제를 위한 동기화로 나뉩니다.
실행 순서 제어: 프로세스를 올바른 순서대로 실행하기
예: 우리는 평소에도 실행 순서 제어를 하고 있습니다. for 문을 사용한 반복문 또한 실행순서를 제어해 우리가 원하는 값을 얻는 일종이라고 볼 수 있습니다.
상호배제: 동시에 접근해서는 안 되는 자원에 하나의 프로세스만 접근하게 하기
예: 1000에 + 200, + 500 연산이 생길 경우, 뒤의 +500 연산이 1000이란 값에 접근이 가능하다면 +200연산이 끝나기전에 1000의 값을 읽어 1700이란 결과가 오지 않고, 1500이란 결과가 생길 수 있습니다.
**경쟁 상태(Race Condition)는 둘 이상의 프로세스나 스레드가 공유 자원에 접근할 때 발생할 수 있는 문제입니다. 이때 각각의 프로세스나 스레드가 공유 자원에 접근하려는 순서나 시간차에 따라 예상치 못한 결과가 발생할 수 있습니다. 예를 들어, 공유 자원에 동시에 쓰는 경우, 값이 덮어써질 수 있거나 일부 데이터가 유실될 수 있습니다.
해결: 상호배제(Mutual Exclusion)를 사용하여 한번에 하나의 프로세스나 스레드만 공유 자원에 접근하도록 제한합니다.
**데드락(Deadlock)은 둘 이상의 프로세스나 스레드가 서로 상대방의 자원을 기다리며 무한정 대기하는 상황을 의미합니다. 데드락은 둘 이상의 자원이 필요한 경우에 발생할 수 있습니다. 예를 들어, 프로세스 A가 자원 1을 가지고 자원 2를 기다리며 대기하고, 프로세스 B는 자원 2를 가지고 자원 1을 기다리며 대기하는 상황입니다. 이러한 상황에서 프로세스나 스레드가 진행할 수 없게 되어 시스템이 멈추는 결과를 초래할 수 있습니다. 필수 요건 4가지(상호배제, 점유대기, 비선점, 순환대기)이 필요하며, 이는 다음장에서 정리하도록 하겠습니다.
해결: 상호배제와 함께 교착상태 탐지(Deadlock Detection) 및 회피(Deadlock Avoidance) 알고리즘을 사용하여 상호배제를 풀고 다른 자원을 사용할 수 있도록 합니다.
공유자원과 임계구역이란?
**공유자원이란, 여러 프로세스나 스레드가 함께 사용해야 하는 데이터나 자원을 의미합니다. 하지만 공유자원에 대한 동시 접근이 일어날 경우, 데이터의 일관성이 깨지거나 예상치 못한 결과가 발생할 수 있습니다. 이를 방지하기 위해 공유자원에 접근하는 코드를 임계구역(critical section)으로 지정하여, 하나의 프로세스나 스레드만이 임계구역에 접근할 수 있도록 합니다.
●공유자원은 전역변수, 파일, 입출력 장치, 보조기억장치 등 여러 자원이 있습니다.
**임계구역은 여러 개의 명령어가 포함된 코드 블록이며, 공유자원에 대한 접근을 수행하는 코드 영역입니다. 임계구역에서는 동시에 여러 프로세스나 스레드가 실행되지 않도록, 상호배제, 진행, 유한대기 원칙하에 접근을 제한합니다.
- 상호배제: 한 프로세스가 임계 구역에 진입했다면 다른 프로세스는 임계 구역에 들어올 수 없다.
- 진행: 임계 구역에 어떤 프로세스도 진입하지 않았다면 임계 구역에 진입하고자 하는 프로세스는 들어갈 수 있어야 한다.
- 유한대기: 한 프로세스가 임계 구역에 진입하고 싶다면 그 프로세스는 언젠가는 임계 구역에 들어올 수 있어야 한다.(임계 구역에 들어오기 위해 무한정 대기해서는 안 된다)
*시뮬레이션 코딩테스트 문제에서 반복 변수의 변수명을 중복하여 사용해 고민을 겪은 분이라면 이해가 쉬울 것 같습니다요. :0..
for y in range(3):
for x in range(3):
print("y =", y, "x =", x)
for y in range(3):
for x in range(3):
continue
# y = 0 x = 0
# y = 2 x = 1
# y = 2 x = 2
# y = 1 x = 0
# y = 2 x = 1
# y = 2 x = 2
# y = 2 x = 0
# y = 2 x = 1
# y = 2 x = 2
상호배제 기법은 뮤텍스(mutex)나 세마포어(semaphore) 등의 동기화 기법을 사용하여 구현할 수 있습니다.
임계구역에 대한 적절한 동기화를 구현하지 않으면, 공유자원에 대한 동시 접근으로 인해 데이터의 일관성이 깨지거나 예상치 못한 결과가 발생할 수 있습니다. 예를 들어, 두 개의 스레드가 동시에 같은 변수에 값을 쓰는 경우, 먼저 쓴 스레드의 값이 뒤늦게 쓴 스레드에 의해 덮어써지는 등의 문제가 발생할 수 있습니다. 따라서 공유자원에 대한 동시 접근이 필요한 경우, 적절한 동기화 기법을 사용하여 임계구역을 구현해야 합니다.
동기화기법
뮤텍스(Mutex)와 세마포어(Semaphore), 모니터(Monitor)는 동기화 기법 중 하나로, 공유 자원에 대한 접근을 제어하기 위해 사용됩니다.
**뮤텍스는 상호배제를 위한 기법으로, 하나의 프로세스나 스레드만이 임계구역(Critical Section)에 진입하여 공유 자원을 사용할 수 있도록 합니다. 뮤텍스는 임계구역에 진입하기 전 뮤텍스를 획득하고, 임계구역을 나올 때 뮤텍스를 반환하는 방식으로 사용됩니다. 만약 다른 프로세스나 스레드가 뮤텍스를 획득한 경우, 해당 프로세스나 스레드는 뮤텍스를 획득할 때까지 대기하게 됩니다.
⦿뮤텍스는 동기화 기법이고, 뮤텍스락은 이런 뮤텍스를 구현하기 위한 기술입니다! 뮤텍스락은 뮤텍스 객체와 함께 사용되며, 뮤텍스를 획득하기 위해서는 뮤텍스락을 잠그는(lock) 작업을 수행해야 합니다. 그리고 이를 위해 lock (acquire), unlock (release) 메서드를 사용합니다.
lock, unlock 상태를 쉴새 없이 확인하기 때문에 바쁜대기 라고도 합니다.
**세마포어는 뮤텍스와 비슷한 기법으로, 공유 자원에 대한 접근을 제어하기 위해 사용됩니다. 세마포어는 뮤텍스와 달리 다수의 프로세스나 스레드가 동시에 접근 가능하도록 제어할 수 있습니다. 세마포어는 카운트 값을 가지고 있으며, 이 값이 0인 경우에는 대기 상태로 들어가게 됩니다. 세마포어는 하나의 전역 변수(S 또는 value)와 wait()와 signal() 두 개의 함수로 구성되어 있습니다.
- S는 동시에 접근 가능한 프로세스의 개수를 나타냅니다.
- wait() 함수는 세마포어 값을 감소시키고, 세마포어 값이 0인 경우에는 대기 상태로 들어가게 됩니다.
- signal() 함수는 세마포어 값을 증가시키며, 대기 중인 프로세스나 스레드 중 하나를 실행 대기 상태로 변경합니다. (대기 큐에 넣습니다.)
**모니터(monitor)는 상호배제 기능과 조건 변수(condition variable)를 함께 제공하는 동기화 기법입니다. 모니터는 고급 언어에서 주로 사용되며, 상호배제와 조건 변수를 자동으로 처리하여 프로그래머가 직접 구현할 필요를 없애고, 더욱 안전하고 쉬운 동기화를 구현할 수 있습니다.
뮤텍스와 세마포어는 모두 동기화를 위한 기법으로, 공유 자원에 대한 접근을 제어하여 경쟁 상태나 데드락 문제를 방지할 수 있습니다. 그러나 뮤텍스는 하나의 프로세스나 스레드만이 접근할 수 있도록 제한하는 반면, 세마포어는 다수의 프로세스나 스레드가 동시에 접근 가능하도록 제어할 수 있습니다. 따라서 상황에 따라 적절한 기법을 선택하여 사용해야 합니다.
Swift에서! 뮤텍스와 세마포어를 구현하기 위해서는, Foundation 프레임워크에서 제공하는 NSLock 클래스와 DispatchSemaphore 클래스를 사용할 수 있습니다.
NSLock 클래스는 Objective-C에서 사용되는 클래스이지만, Swift에서도 사용할 수 있습니다. NSLock 클래스는 뮤텍스를 구현하기 위한 클래스로, lock()과 unlock() 메서드를 제공합니다. lock() 메서드를 호출하여 뮤텍스를 획득하고, unlock() 메서드를 호출하여 뮤텍스를 반환합니다. NSLock 클래스는 다음과 같이 사용할 수 있습니다.
import Foundation
let lock = NSLock()
func someFunction() {
lock.lock()
// 임계구역 코드
lock.unlock()
}
DispatchSemaphore 클래스는 세마포어를 구현하기 위한 클래스로, wait()과 signal() 메서드를 제공합니다. wait() 메서드를 호출하여 세마포어 값을 감소시키고, 세마포어 값이 0인 경우에는 대기 상태로 들어가게 됩니다. signal() 메서드를 호출하여 세마포어 값을 증가시키며, 대기 중인 프로세스나 스레드 중 하나를 실행 대기 상태로 변경합니다. DispatchSemaphore 클래스는 다음과 같이 사용할 수 있습니다.
import Foundation
let semaphore = DispatchSemaphore(value: 1)
func someFunction() {
semaphore.wait()
// 임계구역 코드
semaphore.signal()
}
위의 예제에서는 value 매개변수에 1을 전달하여 세마포어를 생성합니다. 이렇게 생성된 세마포어는 한 번에 하나의 프로세스나 스레드만이 임계구역에 진입할 수 있도록 제어합니다. 만약 value 매개변수에 다른 값을 전달하면, 해당 값만큼 동시에 접근 가능한 프로세스나 스레드의 수가 늘어납니다.
Swift에서는 모니터를 직접 구현할 필요 없이, Objective-C나 Swift와 같은 고급 언어를 사용하여 Grand Central Dispatch(GCD) 프레임워크를 활용하여 모니터를 구현할 수 있습니다.
GCD의 Dispatch Queue는 모니터의 개념과 유사합니다. Dispatch Queue는 각각의 큐에서 실행되는 작업들이 상호배제되도록 보장하며, 조건 변수의 개념에 해당하는 Dispatch Group, Dispatch Semaphore 등을 제공합니다.
아래는 GCD를 사용하여 모니터를 구현하는 예시입니다.
class Monitor {
let queue = DispatchQueue(label: "MonitorQueue")
var count = 0
func increment() {
queue.sync {
count += 1
}
}
func decrement() {
queue.sync {
count -= 1
}
}
func getCount() -> Int {
return queue.sync {
return count
}
}
}
위 예시에서는 DispatchQueue를 사용하여 모니터를 구현하였습니다. increment와 decrement 메서드는 각각 count 변수의 값을 증가시키고 감소시키는 역할을 하며, getCount 메서드는 count 변수의 값을 반환합니다. 각각의 메서드는 DispatchQueue의 sync 메서드를 사용하여 큐에 작업을 추가하고, 작업이 완료될 때까지 대기하도록 설정합니다.
이와 같이 GCD를 사용하여 모니터를 구현하면, 스레드 간 동기화 문제를 쉽게 해결할 수 있습니다.
**DispatchQueue**
지난 포스팅에 이어 DispatchQueue에 대한 자세한 설명을 하면!
DispatchQueue는 Grand Central Dispatch(GCD)를 기반으로 하는 비동기 처리를 위한 클래스입니다. DispatchQueue는 FIFO(First-In-First-Out) 원칙에 따라 작업 항목을 큐잉하고, 시스템에서 사용 가능한 스레드 풀(Thread Pool)을 이용하여 작업 항목을 실행합니다. DispatchQueue는 스레드 관리, 작업 스케줄링 및 동기화를 자동으로 처리하여 개발자가 직접 스레드를 관리하거나 동기화를 구현할 필요가 없도록 합니다.
DispatchQueue는 sync() 및 async() 메서드를 제공하며, sync() 메서드는 현재 스레드에서 작업 항목을 동기적으로 실행합니다. 즉, sync() 메서드가 호출된 스레드는 작업 항목이 완료될 때까지 대기합니다. 반면, async() 메서드는 작업 항목을 비동기적으로 실행하며, async() 메서드가 호출된 스레드는 작업 항목이 완료될 때까지 대기하지 않고 즉시 반환됩니다.
DispatchQueue는 또한 특정 조건에 따라 작업 항목의 실행을 제어하는 Quality of Service(QoS)를 지원합니다. QoS는 우선순위와 관련이 있으며, 시스템에서 사용 가능한 자원을 효율적으로 활용하여 작업 항목의 실행을 최적화합니다. 예를 들어, 유저 인터페이스 업데이트와 같은 높은 우선순위의 작업은 UI 스레드에서 실행되어야 하므로, QoS를 .userInteractive로 설정하여 UI 스레드에서 실행될 수 있도록 합니다.
Swift의 DispatchQueue는 비동기적인 작업 처리를 간편하게 구현할 수 있도록 하는 강력한 도구입니다. 따라서 애플리케이션 개발 시 비동기 처리를 위해 활용할 수 있으며, 스레드 관리와 동기화 구현의 복잡성을 줄여 개발 생산성을 높일 수 있습니다.