# TIL 21.1.12

simoniful·2022년 1월 12일
1

Swift

목록 보기
9/9

동시성

컨텍스트 스위칭

멀티 프로세스 환경에서 CPU가 어떤 하나의 프로세스를 실행하고 있는 상태에서, 인터럽트 요청에 의해 다음 순위의 프로세스가 실행되어야 할 때, OS 스케줄러가 기존의 프로세스의 상태 또는 레지스터 값(Context)을 저장하고 CPU가 다음 프로세스를 수행하도록 새로운 프로세스의 상태 또는 레지스터 값(Context)으로 교체하는 작업이다.

하이퍼 스레딩

가상의 두 번째 CPU를 인식시킨 다음 단일 CPU의 실행 유닛에 두 스레드를 번갈아 가며 실행하도록 하는 기법으로, 하나의 CPU를 두 개의 CPU인 것처럼 사용해 스레드를 동시에 처리하는 것을 말한다.

프로세스(process)

실행파일을 클릭했을 때, 메모리(RAM) 할당이 이루어지고, 이 메모리 공간으로 코드가 올라간다. 이 순간부터 이 프로그램은 '프로세스'라 불리게 된다. 즉, 사용자가 작성한 프로그램이 운영체제에 메모리 공간을 할당받아 실행 중인 상태이다.

운영체제로부터 시스템 자원을 할당받는 작업의 단위로 각각의 독립된 메모리 영역 (Code, Data, Stack, Heap)을 각자 할당 받아 안정적이다. 따라서 프로세스끼리는 서로의 변수나 자료구조에 대해 절대 접근할 수 없다.

  • Data, Resource, Thread로 구성

스레드(thread)

프로세스 내에서 실제 작업을 수행하는 흐름으로 모든 프로세스는 한 개 이상의 스레드가 존재하며, 두 개 이상의 스레드를 가지는 프로세스를 멀티 스레드 프로세스라고 한다. 물리적 스레드와 소프트웨어적 스레드가 있다.

쓰레드는 프로세스가 아닌, 프로세스 내에서 동작되는 것이기 때문에 메모리 영역을 독립적으로 할당받지 못한다. Code, Data, Heap 영역은 공유하고 Stack 영역만 독립적으로 할당하여 Task를 처리한다.

GCD

Sync와 Async

개별 스레드의 관점에서

  • Sync: Queue에 보낸 작업이 끝날 때까지 기다렸다가 다음 작업을 수행
  • Async: Queue에 작업을 보낸 직후 바로 다음 작업을 수행

Serial(Main)와 Concurrent(Global)

Queue에서의 작업 분배 관점에서

  • Serial(Main): Queue에서 작업을 분배하지 않고 대기열에 있는 작업을 다른 하나의 대상 스레드로 몰아서 순차적으로 작업, Main-thread 집중

  • Concurrent(Global): Queue에서 작업을 분배하여 여러 스레드로 동시 다발적으로 수행하도록 작업, Multi-thread 분산

  • Custom(Debug): 디버그 영역에서 확인 가능하도록 스레드에 대한 label을 부여하는 것이 가능

확인 사항

Main - async / Global - async

1. main 큐에서 다른 큐로 작업을 보낼 때 sync를 사용하면 안된다

Queue로 해당 작업을 보내어 할당하게 되면서, 다른 스레드로 Task를 동기적으로 분배한다. 하지만, 다른 스레드로 동기적(sync)으로 보내는 코드라도 결국, 실질적으로 기다렸다가 메인 스레드에서 작업 진행

2. 현재와 같은 큐에 sync로 작업을 보내면 안된다

//데드락 발생 가능성 있음
DispatchQueue.global().async {
  DispatchQueue.global().sync 
}
//데드락 발생 가능성 없음
DispatchQueue.global(qos: .utility).async {
  DispatchQueue.global().sync 
}

같은 큐에 보내게 되면 같은 스레드에 배치될 수 있는데, 해당 스레드가 sync 로 인해 멈춰있는 상황이라면 데드락 상황이 발생.

참고로 글로벌 큐는 Qos에 따라 각각 다른 큐 객체를 생성. 즉 DispatchQueue.global(qos: .utility) 와 DispatchQueue.global()는 다른 큐. 따라서 각각 다른 Qos 큐라면 쓰레드가 겹칠일이 없기 때문에, 데드락 발생 가능성이 없다.

3. main 스레드에서 DispatchQueue.main.sync를 사용하면 안된다


default로 main 스레드에서 작업하기에 Queue로 해당 작업을 보내어 할당하게 되면서, 무한정으로 완료를 기다리게 되므로 DeadLock(교착상태) 발생

4. QoS(GCD Quality Of Service)

Queue에서 작업 시작의 우선 순위를 정하여 분배하면서 어느정도 작업의 컨트롤이 가능, 시스템은 QoS정보를 사용하여 스케쥴링, CPU 및 I/O처리량 및 타이머 대기 시간과 같은 priority를 조정. 결과적으로 수행된 작업은 성능과 에너지 효율성 간의 균형을 유지한다. 하지만, 시작점은 컨트롤 할 수 있지만, 끝나는 시점에 대한 통제가 어렵다는

  • User-interactive : main thread에서 작업, 사용자 인터페이스(UI) 새로고침 또는 애니메이션 수행과 같이 사용자와 상호작용 하는 작업. 작업이 신속하게 수행되지 않으면, UI가 중단된 상태로 표시될 가능성이 있으므로 따라서, 반응성과 성능에 중점

  • User-initiated : 사용자가 시작한 작업이며, 저장된 문서를 열거나, 사용자 인터페이스에서 무언가를 클릭할 때 작업을 수행하는 것과 같은 즉각적인 결과가 필요. 사용자 상호작용을 계속하려면 작업이 필요합니다. 반응성과 성능에 중점

  • Utility : 작업을 완료하는 데 약간의 시간이 걸릴 수 있으며, 데이터 다운로드 또는 import와 같은 즉각적인 결과가 필요하지 않습니다. 작업은 초, 분 단위로 소요되며, 유틸리티 작업에는 일반적으로 사용자가 볼 수 있는 progress bar가 있습니다. 반응성, 성능 및 에너지 효율성 간에 균형을 유지하는 데 중점

  • Background : 백그라운드에서 작동하며, indexing, 동기화 및 백업과 같이 사용자가 볼 수 없는 작업. 분 또는 시간 단위로 소요되며, 에너지 효율성에 중점

클로저에서의 객체에 대한 캡처와 컴플리션 핸들러 주의

동작해야 할 task 를 queue에 보낸다는것은 결국 클로저를 통해 보내는 것. 따라서 객체에 대한 캡처 현상 발생할 수 있게 되고, 자칫하면 순환 참조가 생길 수도 있다. 따라서 앞서 배운 약한/미소유 참조를 활용하여 이를 예방해야한다

해당 queue에 보낸 task의 완료 시점에 대해서는 completion handler를 통하여 task별로 받는 방법이 있으나, 병렬적으로 관리와 noti가 가능한 DispatchGroup을 활용하는 것이 편리하다.

DispatchGroup

서로 다른 Task의 그룹화를 통하여 Queue에 보낸 작업들을 완료할 때 까지 기다리고, 완료 시 notification을 받도록 구성 가능하다. 여러 스레드로 분배된 작업들의 종료 시점을 각각이 아닌 하나로 그룹지어서 한번에 파악 가능!

1. group

내부적으로 비동기 활용 코드가 없는 경우 async 키워드에 group을 설정하면서 task를 바라보고 notification을 받을 수 있다. 하지만, 내부 네트워크 통신과 같은 백드라운드 비동기 함수가 포함되는 경우, 중복적으로 쓰레드 관련 제어 구문이 들어가기에 기대했던 정상적인 notify를 받을 수 없다.

let group = DispatchGroup()
DispatchQueue.global().async(group: group) {
	for i in 1...100 {
		print(i, terminator: " ")
	}
}

DispatchQueue.global().async(group: group) {
	for i in 101...200 {
		print(i, terminator: " ")
	}
}

DispatchQueue.global().async(group: group) {
	for i in 201...300 {
		print(i, terminator: " ")
	}
}

group.notify(queue: .main) {
	print("작업완료")
}

2. wait

notify 외에도 해당 그룹의 모든 작업이 완료때까지 현재 스레드를 block 시킬 수 있다. group1.wait(timeout:) 처럼 timeout 파라미터를 통해 얼마나 기다릴지에 대한 시간을 지정할 수 있다. 이를 통해 일정 시간 이후에는 다음 작업이 마저 진행된다. (그렇다고 시간내에 안끝난 작업을 멈추는건 아니고, 다른 스레드에서 계속 진행)

let group1 = DispatchGroup()
DispatchQueue.global().async(group: group1) { }
DispatchQueue.global().async(group: group1) { }
DispatchQueue.global(qos: .utility).async(group: group1) { }

let timeoutResult = group1.wait(timeout: .now() + 60)
switch timeoutResult {
  case .success:
    print("60초 안에 그룹 내 모든 task 끝냄")
  case .timedOut:
    print("60초 안에 그룹 내 모든 task 못끝냄")
}

wait() 함수를 실행하는 곳과 task를 보내는 큐 유의!

    1. wait() 함수를 실행하는 곳이 메인스레드이면 안된다!
      group1.wait() 를 통해 DispatchGroup에 대해 wait를 걸게 되는데, 여기서 주의할 점은 해당 함수를 실행하는 곳이 main큐(메인스레드)면 안된다. 왜냐하면 wait는 함수를 호출한 현재의 스레드를 블럭하기 때문에, 메인 스레드에서 실행하면 메인 스레드가 멈추는, 즉 task들이 다른 스레드에서 실행되는 시간만큼 앱이 멈추는 상황이 발생
    1. 그룹 내의 task는 wait가 실행되고 있는 현재의 스레드로 할당이 되어선 안된다.
      왜냐하면,wait 를 실행하고 있는 스레드는 멈춰있을텐데 그 곳으로 task 가 할당된다면 데드락 상황이 발생한다. 현재의 스레드로 task를 할당 할 가능성이 있는 큐, 즉 현재 큐(현재 스레드에 wait을 실행하도록 할당한)로 task 를 보내면 안된다.

3. Enter / leave

내부적으로 비동기 활용 코드(animate, URLSession 등)가 있는 경우, Enter / leave를 통하여 Task의 완료 시점을 ARC를 통해서 참조를 관리하는 것과 유사하게 task reference count를 통한 관리가 가능하다.

let group = DispatchGroup()
group.enter()
	request(url: url1) { image in
		print("image1")
		group.leave()
}

group.enter()
	request(url: url2) { image in
		print("image2")
		group.leave()
}

group.enter()
	request(url: url3) { image in
		print("image3")
		group.leave()
}

group.notify(queue: .main) {
	print("끝")
}

Race Condition

다른 쓰레드에 같은 공유 자원에 접근하게 되는 경우 결과가 그때 그때 다르게 나올 수 있다. race condition은 random하게 발생하기도 해, 디버깅이 매우 어렵다. random하다는 것은 버그를 다시 재현하는 데에 명확한 과정이 없다는 것을 의미하기도 한다.

따라서, 동일한 데이터 접근 시에 경쟁하는 상황을 확인하고 방지하기 위해서 Scheme에서 Thread Sanitizer 옵션을 통해서 Debug 확인하여 이에 대하여 대응할 수 있다. race condition을 완벽히 피하는 방법은 없지만 queuing이나 GCD를 사용하는 등의 테크닉을 통해 훨씬 안전한 코드를 작성할 수 있다.

물론 이것 또한 실제적인 thread-safe한 방식을 적용에 있어서도 조금더 디테일한 컨트롤이 필요하다. 비동기 코드를 작성할 때 해당 코드들이 동시에(concurrently) 호출되었을 때 어떻게 동작할지 생각해보자!

  • serial queue + sync 조합의 엄격한 thread-safe 처리
  • concurrent queue + Dispatch Barrier

DispatchWorkItem

클로저 안에 넣어서 원하는 작업을 처리하는 것과 달리 class로 캡슐화하여 해당 task를 관리하고 싶어진다면 DispatchWorkItem 구문을 활용하여 보다 관리에 있어서 용이성을 높일 수 있다. 기존의 구문과 마찬가지로 Qos를 선정하여 task의 우선 순위를 지정하는 것도 가능하다. 이렇게 정의된 DispatchWorkItem 은 async(execute:) 라는 DispatchQueue의 instance method를 통해 큐에 보낼 수 있다. perform() 메소드를 통해 현재 스레드에서 sync 하게 동작도 가능하다.

// 다른 스레드를 활용한 비동기적 구성 
DispatchQueue.global().async(execute: utilityItem)
// 현재 스레드를 활용한 동기적 구성 
utilityItem.perform()

1. cancel

  • 작업 실행 전
    cancel을 요청하면 대기 중인 Queue에서 task가 제거된다.
  • 작업 실행 중
    작업이 멈추지는 않고 DispatchWorkItem 의 속성인 inCancelled 가 true 로 설정된다.

2. notify(queue:execute:)

작업 A가 끝난 후 작업 B가 특정 queue에서 실행(execute)되도록 지정할 수 있다.

let itemA = DispatchWorkItem { }
let itemB = DispatchWorkItem { }
itemA.notify(queue: DispatchQueue.global(), execute: itemB)

DispatchSemaphore

Race Condition에서 보았듯 메모리 환경에는 공유자원이라는 개념이 있다. 공유자원을 안전하게 관리하기 위해서는 상호배제(Mutual exclusion)를 달성하는 기법이 필요하다. 물론 완벽하게 데이터 무결성을 보장하는 것은 힘들지만 GCD에서는 이에 대한 방안을 제공한다.

👉🏻 뮤텍스(Mutex)와 세마포어(Semaphore)의 차이

쉽게 말하면 정수 변수로서, 멀티프로그래밍 환경에서 공유 자원에 대한 접근을 제한하는 방법으로 공유 자원에 접근하는 작업의 수를 제한할 때 사용한다.

1. 동시 작업의 갯수 제한

iOS 에서는 세마포어를 위해 DispatchSemaphore 라는 객체를 이용한다. 공유 자원에 접근 가능한 (혹은 한번에 실행 가능한) 작업 수를 명시하고, 임계 구역에 들어갈때는 semaphore의 wait()를, 나올때는 signal()을 호출한다.

let semaphore = DispatchSemaphore(value: 2)
for i in 1...3 {
  semaphore.wait() // semaphore 감소
  DispatchQueue.global().async() {
    // 임계 구역(critical section)
    print("공유 자원 접근 시작 \(i) 🌹")
    sleep(3)
    print("공유 자원 접근 종료 \(i) 🥀")
    semaphore.signal() //semaphore 증가
  }
}
// 공유 자원 접근 시작 1 🌹
// 공유 자원 접근 시작 2 🌹
// 공유 자원 접근 종료 2 🥀
// 공유 자원 접근 종료 1 🥀
// 공유 자원 접근 시작 3 🌹
// 공유 자원 접근 종료 3 🥀

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

두 스레드가 특정 이벤트의 완료 상태를 동기화 하는 경우에 유용하다는 것입니다.

  • 스레드 A는 작업 A 실행 중
  • 스레드 B는 작업 A가 끝난 후에 무언가를 실행하려고 함

스레드 B(소비자)는 예상된 작업을 기다리기 위해 wait를 호출하고, 스레드 A(생성자)는 작업이 준비되면 signal를 호출하면 스레드 B가 작업 A의 완료 상태를 동기화할 수 있다. DispatchSemaphore를 해당 용도로 사용할때는 초기값을 0으로 설정한다.

// DispatchSemaphore 초기값 0으로 설정
let semaphore = DispatchSemaphore(value: 0)
print("task A가 끝나길 기다림")
// 다른 스레드에서 task A 실행
DispatchQueue.global(qos: .background).async {
  // task A
  print("task A 시작!")
  print("task A 진행 중")
  print("task A 끝!")
  // task A 끝났다고 알려줌
  semaphore.signal()
}
// task A 끝날 때까지는 value 가 0이라, task A 종료까지 block
semaphore.wait()        
print("task B 완료됨")

task A가 끝나지 않았다면 (즉 signal() 이 실행되지 않았다면) semaphore.wait() 이 후의 작업은 실행되지 않는다. 왜냐면 그전까지 세마포어 값은 0이기 때문이다.

NSOperationQueue

GCD는 우리가 Queue에 작업을 보내면 그에 따른 스레드를 적절히 생성해서 분배해주는 방법이다. Operation에서 사용하는 queue의 이름은 Operation Queue이며, 사실 내부적으론 GCD 위에서 동작한다. 특징적인 부분은 다음과 같다

  • 동시에 실행할 수 있는 동작의 최대 수 지정
  • 동작 일시 중지 및 취소

Async / Await

profile
소신있게 정진합니다.

0개의 댓글