세마포어(Semaphore)란?
세마포어란 멀티 스레드 환경 혹은 멀티 프로세스 환경에서 공유 자원에 대한 접근을 일부 스레드 혹은 프로세스로 제한하기 위해 사용되는 방법이다. 코루틴 라이브러리 또한 이러한 세마포어를 구현한 Semaphore 객체를 가지고 있는데, 이 Semaphore 객체는 허락을 받은 일부 코루틴만 임계 영역(Critical Area)에 접근할 수 있도록 한다.
Semaphore 객체를 쉽게 설명하기 위해, 일반적으로 Semaphore 객체가 가진 '허락(permit)'을 키에 비유해보자. Semaphore 객체는 여러 개의 키를 가지고 있고, 이 키의 개수는 개발자에 의해 설정된다. 이 키들은 바로 임계 영역에 접근할 수 있는 키이며, 이 키를 빌린 코루틴만 임계 영역에 접근할 수 있다. 예를 들어 다음과 같이 세 개의 코루틴과 두 개의 키를 가진 Semaphore 객체가 있다고 해보자.
이 세 개의 코루틴이 Semaphore 객체에 동시에 접근해 키를 빌려달라고 한 경우 두 개의 코루틴(코루틴1, 코루틴2)만 키를 빌릴 수 있고, 코루틴3은 Semaphore 객체에 남은 키가 없어 키가 반납될 때까지 일시 중단 하게 된다.
코루틴1과 코루틴2는 키를 가지고 있으므로, 임계 영역에 접근해 작업을 할 수 있다.
이제 코루틴1이 작업을 완료한 후에 키를 반납하는 경우를 생각해 보자. 코루틴1이 작업을 완료한 후 키를 반납하면, 이 키는 대기하고 있던 코루틴3에게 분배되며 코루틴3이 키를 빌리고, 재개되어 임계영역의 작업을 실행할 수 있게 된다.
이런 방식으로 Semaphore은 임계 영역의 작업을 일부 코루틴만 실행할 수 있도록 제한한다.
Semaphore 사용법
Semaphore 객체 생성하기
코루틴 라이브러리에는 Semaphore가 인터페이스로 선언돼 있으며, 그 구현체는 SemaphoreImpl이다. 코루틴 라이브러리는 SemaphoreImpl의 생성자를 외부로 노출하지 않으며, Semaphore 객체를 만들기 위해서는 코루틴 라이브러리에서 공개된 API인 Semaphore 함수를 사용해야 한다. Semaphore 함수는 다음과 같이 선언되어 있다.
public fun Semaphore(permits: Int, acquiredPermits: Int = 0): Semaphore = SemaphoreImpl(permits, acquiredPermits)
각 인자에 대한 설명은 다음과 같다
- permits: 사용 할 수 있는 키의 개수 설정
- acquirePermits: 이미 획득된 키의 개수 설정
permits를 통해 사용할 수 있는 키의 개수를 설정할 수 있으며, acquiredPermits를 통해 이미 빌려준 키의 개수를 설정할 수 있다. 즉, (permits-acquiredPermits)가 Semaphore가 빌려줄 수 있는 키의 개수가 된다.
Semaphore 객체 사용해보기
Semaphore 객체를 사용하기 위해 네트워크 요청 작업을 수행하는 RemoteRepository 객체를 다음과 같이 만들어보자.
class RemoteRepository() {
suspend fun networkCall() {
delay(1000L) // 네트워크 작업 가정
}
}
이 RemoteRepository 객체는 서버와의 통신을 수행하는 객체이며, 위와 같이 코드를 작성하면 외부에서 수십 개의 코루틴이 networkCall 함수를 호출할 경우 networkCall을 동시에 수행할 수 있어 서버에 부담을 줄 수 있다.
이제 서버의 부담을 줄이기 위해, 위 객체를 Semaphore 객체를 사용해 동시에 요청할 수 있는 네트워크 요청의 개수를 2개로 제한해 보자. Semaphore 객체의 acquire 함수를 호출하면 남아 있는 키가 있을 경우 키를 빌리고 남아 있는 키가 없으면 일시 중단 후 대기하며, release 함수를 호출하면 키가 반납된다. 그러면 전체 코드는 다음과 같아진다.
class RemoteRepository() {
private val semaphore = Semaphore(permits = 2) // 두 개의 키를 가지고 있도록 설정
suspend fun networkCall() {
semaphore.acquire() // 키 빌리기
delay(1000L)
semaphore.release() // 키 반납
}
}
이제 실제로 네트워크 요청이 최대 두 개로 제한되는지 확인하기 위해 다음 코드를 만들어 실행해 보자. 이 코드는 백그라운드 스레드에서 실행되는 10개의 코루틴을 만들어 RemoteRepository 객체의 networkCall을 수행하도록 한다. 그러면 각 작업이 1초(1000밀리 초)가 걸리는데 최대 2개만 동시에 수행할 수 있으므로, 총 걸린 시간은 5초가량이 나와야 한다.
fun main() = runBlocking<Unit> {
val startTime = System.currentTimeMillis()
val remoteRepository = RemoteRepository()
withContext(Dispatchers.IO) {
repeat(10) {
launch {
remoteRepository.networkCall()
}
}
}
println("총 걸린 시간 >> ${System.currentTimeMillis() - startTime}밀리초")
}
실제 실행 결과는 다음과 같다. 총 걸리시간이 5042 밀리초로 5초 정도 나온 것을 볼 수 있다.
위의 예제에서 다룬 네트워크 요청 외에도 다양한 경우에 Semaphore 객체를 사용해 동시 접근할 수 있는 코루틴의 개수를 제한할 수 있다.
정리
- 코루틴에서 세마포어를 사용하기 위해서는 Semaphore 객체를 사용하면 된다.
- Semaphore 객체의 키를 빌리기 위해서는 acquire 함수를 사용하면 되며, 반납하기 위해서는 release 함수를 사용하면 된다.
- 키를 빌린 코루틴만 임계 영역에 접근할 수 있다.