[Swift] DispatchSemaphore

leeyoung·2022년 7월 28일

세마포어

그 운영체제에서 배웠던 것을 여기서 다시 마주할 줄 몰랐다

세마포어에 대해 설명하기 이전에 Critical Section 부터 Mutex 설명하고...

그러면 너무 길어지니까 그건 운영체제 쪽에서 따로 다루고

세마포어

세마포어는 위키에서 설명하기를

멀티프로그래밍 환경에서 공유자원에 대한 접근을 제한하기 위해 사용되는 정수 변수

세마포어는 원자적으로 제어되는 정수 변수로, 일반적으로 세마포어의 값이 0이면 자원에 접근할 수 없도록 블럭을 하고 0보다 크면 접근함과 동시에 세마포어의 값을 1 감소시킨다. 반대로 종료하고 나갈 때에는 세마포어의 값을 1 증가시켜 다른 프로세스가 접근할 수 있도록 한다.

보통

세마포어 (Semaphore) > 도리의 디지털라이프

이런 그림으로 설명하기도 하고

계수형 세마포어 (Counting Semaphore) > 도리의 디지털라이프

열쇠로 비유하여 이런 그림으로 설명하기도 한다.

세마포어라는 정수형 변수 값을 원자성 연산이 PV연산으로 증감시킨다.

(원자성 연산은 더이상 쪼갤 수 없는 연산으로 세마포어 값을 변경하는 동안 다른 연산이 동시에 이루어질 수 없다는 것)

  • P는 임계 구역 들어가기 전에 수행(세마포어 값을 감소시킨다)

  • V는 임계 구역 나올 때 수행(세마포어 값을 증가시킨다)

세마포어 값이 0이되면 임계영역에 들어가지 못하고 대기한다.

그래서 흔히 세마포어를 열쇠에 비유하는 것이다.

들어갈 수 있는 열쇠 개수가 세마포어이고

열쇠 개수가 0 미만이 되면 임계영역을 쓰고 있는 프로세스, 스레드가 반납하기 전까지 대기한다.

DispatchSemaphore

아무튼 이런 녀석...

'공유 자원에 대한 접근 제한'이 키워드인데

이걸 iOS에서 언제 사용하는지 보자

작업 개수 제한

DispatchSemaphore를 이용해서 작업 개수를 제한 할 수 있다.

// 공유리소스에 접근가능한 작업수를 4개로 제한
let semaphore = DispatchSemaphore(value: 4)

for i in 1...10 {
    semaphore.wait()
    DispatchQueue.async(group: group) {
        print("\(i) 시작")
        sleep(3)
        print("\(i) 종료")
        semaphore.signal()
    }
}

DispatchSemaphore(value: 4)로 semaphore를 초기화 한다.

semaphore.wait()는 P로 semaphore를 감소시키는 것(열쇠를 가져가는 것)

semaphore.signal()은 V로 semaphore를 증가시키는 것(열쇠를 반납하는 것)

여기까지는 이론적으로, 머리로 이해했어

언제 사용하게 될지 잘은 모르겠지만 이해는 했어

두 스레드의 특정 이벤트 완료 상태 동기화

그런데 그 상태로 다른 예제 코드를 딱 봤는데

let semaphore = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
  // 오래... 걸리는 작업
  semaphore.signal()
}
_ = semaphore.wait(timeout: .distantFuture)

여기서 엥?

일단 semaphore 값을 0으로 초기화 한것 부터 엥?

어떤 의미로 이렇게 사용되는지 이해가 되지 않았다.

우선 코드 상의 의미부터 보자

semaphore가 0 미만이 되면 thread 진행되지 않는다(freeze)

그러면 코드가 위에서 부터 진행되면 비동기 처리 작업이 시간이 걸려서 semaphore.wait()를 만나고 semaphore 값이 0 -> -1이 된다. 그리고 비동기 작업이 끝나면 semaphore.signal()을 만나서 -1 -> 0이 되고 thread가 실행을 이어간다.

예제

적당한 예제를 찾아보았다

비동기 함수인 fetchData를 두번 호출하는 예시이다

/// 지연 후에 Int를 String으로 변환하는 함수
func fetchData(_ data: Int, delay: Double, completionHandler: @escaping (String)->()) {
	print("fetchData - 진입")
	
	DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
		print("fetchData - data  \(data)")
		completionHandler("\(data)")
	}
	
	print("fetchData - 종료")
}


/// 비동기 함수 fetchData를 두개 실행
func combineAsyncCalls(completionHandler: @escaping (String)->()) {
	print("combineAsyncCalls - 진입")

	var text = ""
	fetchData(0, delay: 0.4) { text += $0 }
	fetchData(1, delay: 0.2) { text += $0 }
	print("combineAsyncCalls - text  \(text)")
	
	completionHandler(text)
	
	print("combineAsyncCalls - 종료")
}


combineAsyncCalls() {
	print("combineAsyncCalls - 클로저 메소드 진입")
  print("result: \($0)")
	print("combineAsyncCalls - 클로저 메소드 종료")
}

결과값으로

combineAsyncCalls - 진입
fetchData - 진입
fetchData - 종료
fetchData - 진입
fetchData - 종료
combineAsyncCalls - text  
combineAsyncCalls - 클로저 메소드 진입
result: 
combineAsyncCalls - 클로저 메소드 종료
combineAsyncCalls - 종료
fetchData - data  1
fetchData - data  0 

각 fetchData()에서 0.4초, 0.2초 delay 되지만 그것을 기다리고 않고 completionHandler가 실행되기 때문에 reult가 빈값이다. 그리고 두번째로 실행한 fetchData(1, delay: 0.2) { text += $0 }가 delay 시간이 더 짧기 때문에 먼저 프린트 된 것을 볼 수 있다.

세마포어를 사용한 결과이다.

/// 지연 후에 Int를 String으로 변환하는 함수
func fetchData(_ data: Int, delay: Double, completionHandler: @escaping (String)->()) {
	print("fetchData - 진입")
	
	DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
		print("fetchData - data  \(data)")
		completionHandler("\(data)")
	}
	
	print("fetchData - 종료")
}

func combineAsyncCallsWithSemaphore(completionHandler: @escaping (String)->()) {
	var text = ""
	let semaphore = DispatchSemaphore(value: 0)
	
	// 메인 스레드에서 세마포어를 사용할 수 없다
	DispatchQueue.global().async {
		fetchData(0, delay: 0.4) {
			text += $0
			semaphore.signal()
		}
		semaphore.wait()	// 첫번째 fetchData 함수가 완료될때까지 기다린다.
		
		fetchData(1, delay: 0.2) {
			text += $0
			semaphore.signal()
		}
		semaphore.wait()	// 두번째 fetchData 함수가 완료될때까지 기다린다.
	 
		completionHandler(text)
	}
}

combineAsyncCallsWithSemaphore() {
	print("combineAsynccombineAsyncCallsWithSemaphoreCallsWithDispatchGroup - 클로저 메소드 진입 ")
	print("result: \($0)")
	print("combineAsyncCallsWithSemaphore - 클로저 메소드 종료 ")
}

/* 출력 결과
fetchData - 진입
fetchData - 종료
fetchData - data  0
fetchData - 진입
fetchData - 종료
fetchData - data  1
combineAsynccombineAsyncCallsWithSemaphoreCallsWithDispatchGroup - 클로저 메소드 진입 
result: 01
combineAsyncCallsWithSemaphore - 클로저 메소드 종료 
*/

추가 꼼지락

그런데 추가로 드는 생각

코드를 짜다보니까 상속이 필요한데 부모 메서드가 sync 함수인 상황에서 await 해야하는 작업이나 sleep을 해야 할 때가 있다.

그 때 세마포어를 이용하면 async 함수로 바꾸지 않아도 되지 않을까

예를 들어, 아래와 같은 CustomFileManager를 만든다고 하자.

(이건 내가 임의로 만든 예시이기 때문에 그냥 이 세마포어를 사용하기 위해 만든 억지? 예시일 수도 있다.)

class CustomFileManager: FileManager {
  // ...
	override func contentsOfDirectory(atPath path: String) throws -> [String] {
		var contents = try super.contentsOfDirectory(atPath: path)
		// someAsyncFunc()
		// 해당 작업으로 contents가 변경될 수 있기 때문에 동기적으로 실행되어야 한다.
		return contents
	}
  // ...
}

기존에 FileManager의 contentsOfDirectory로 특정 path에 있는 파일, 폴더 목록을 들고 올 수 있다.

FileManager를 상속 받아 사용하는 CustomFileManager에서 목록을 그대로 불러오는 것이 아니고 특정 비동기적 작업(예를 들어, 서버에서 값을 받아 온다던가..)을 통해 가공하여 반환하게 하는 함수로 바꾸고 싶다고 하자.

let fileManager = CustomFileManager()
let contents = try fileManager.contentsOfDirectory(atPath: path)

class CustomFileManager: FileManager {
	var contents: [String] = []
	
	override func contentsOfDirectory(atPath path: String) throws -> [String] {
		let semaphore = DispatchSemaphore(value: 0)
		contents = try super.contentsOfDirectory(atPath: path)
		
		Task {
			let new = await someAsyncContents()
			contents.append(contentsOf: new)
			semaphore.signal()
		}
		semaphore.wait()

		return contents
	}

	private func someAsyncContents() async -> [String] {
		// ...
		return [ ... ]
	}
}

someAsyncContents() 함수를 통해 받아온 String array를 append해서 반환하게 하고 싶다고하면 이렇게 할 수 있을 것 같다.

Task로 감싸고 해당 Task가 끝날때 까지 기다리게 하는 것...

(만약 contents를 contentsOfDirectory의 지역 변수로 선언하고 Task 내부에서 contents에 append하면

Mutation of captured var 'contents' in concurrently-executing code 에러가 발생하기 때문에 클래스 변수로 임의로 선언했다. )

근데 이렇게 하면 안좋다고 한다.

On the other hand, primitives like semaphores and condition variables are unsafe to use with Swift concurrency. This is because they hide dependency information from the Swift runtime, but introduce a dependency in execution in your code. Since the runtime is unaware of this dependency, it cannot make the right scheduling decisions and resolve them. In particular, do not use primitives that create unstructured tasks and then retroactively introduce a dependency across task boundaries by using a semaphore or an unsafe primitive. Such a code pattern means that a thread can block indefinitely against the semaphore until another thread is able to unblock it. This violates the runtime contract of forward progress for threads.


반면에 세마포어 및 조건 변수와 같은 기본 요소는 Swift 동시성과 함께 사용하기에 안전하지 않습니다. 이는 Swift 런타임에서 종속성 정보를 숨기지만 코드 실행 시 종속성을 도입하기 때문입니다. 런타임은 이 종속성을 인식하지 못하기 때문에 올바른 일정 결정을 내리고 해결할 수 없습니다. 특히, 구조화되지 않은 작업을 생성한 다음 세마포어 또는 안전하지 않은 프리미티브를 사용하여 작업 경계를 넘어 소급적으로 종속성을 도입하는 프리미티브를 사용하지 마십시오. 이러한 코드 패턴은 스레드가 다른 스레드가 차단을 해제할 수 있을 때까지 세마포에 대해 무기한 차단할 수 있음을 의미합니다. 이것은 스레드에 대한 순방향 진행의 런타임 계약을 위반합니다.

참고

  • 참고글
  • [기타 참고 예제 - qteveryday tistory](

0개의 댓글