Concurrency Problems

박성민·2021년 7월 7일
0

iOS

목록 보기
24/30

Race conditions

  • (앱 자체도 포함해서) 같은 process 내에 thread들은 동일한 주소공간을 공유합니다. 즉, 각각의 thread가 동시에 같은 공유 resource를 읽고 쓰려고 합니다. 주의하지않으면 여러 thread가 동시에 동일한 변수에 쓰려고 하는 경합조건(Race Conditions)이 발생할 수 있습니다.
  • 2개의 thread가 실행중이며 두 thread가 모두 객체의 count 변수를 업데이트 하는 경우를 예로 들어보겠습니다. 읽기 및 쓰기는 컴퓨터가 단일작업으로 실행할 수 없는 개별 task입니다. 컴퓨터는 1틱(tick) 당 한개의 작업을 실행할 수 있는 clock cycle에 따라 작동합니다.
    Thread1 과 thread2 모두 count를 업데이트 하려한다면 아래와 같이 작성할 수 있습니다.
count += 1
  • 문제 없는것 같죠? 위의 코드를 구성요소로 세분화하고 약간의 설명을 추가한다면 아래와 같습니다:
    1. 변수 count의 값을 메모리에 로드합니다.
    2. 메모리의 count의 값을 1 증가시킵니다.
    3. 업데이트된 count를 다시 disk에 기록합니다.
  • 위의 그림은 다음과 같습니다.
    • Thread1 은 thread2보다 먼저 clock cycle을 시작하고 count의 값 1을 읽어옵니다.
    • 2번째 clock cycle, thread1은 메모리안에 값을 2로 업데이트합니다. thread2는 count로부터 값 1을 읽어옵니다.
    • 3번째 clock cycle, thread1은 값 2를 다시 count에 기록합니다. 그러나 thread2는 이제 메모리안의 값 1을 2로 업데이트하고 있습니다.
    • 4번째 clock cycle, thread2도 또한 값 2를 count에 기록합니다. 2개의 개별 thread가 모두 값을 업데이트 했기 때문에 값 3을 예상했을 것입니다.
  • 이러한 유형의 race condition은 비결정론적(한가지 행동이 여러가지 결과를 만들수 있는 환경)특성으로 인해 복잡한 디버깅으로 이어집니다. Thread 1이 clock cycle을 2개만 먼저 시작했다면 값 3이 예상대로 나왔을 것입니다. 일반적으로 race condition은 serial queue를 통해 해결할 수 있습니다. 프로그램에 동시에 접근해야하는 변수가 있다면 아래와 같이 private queue로 wrapping할 수 있습니다.
private let threadSafeCountQueue = DispatchQueue(label: "...") //serial queue
private var _count = 0
public var count: Int {
  get {
    return threadSafeCountQueue.sync {
			_count 
		}
	} 
	set {
    threadSafeCountQueue.sync {
      _count = newValue
    }
	} 
}

threadSafeCountQueue는 serial queue입니다. 따라서 한번의 하나의 작업만 시작할 수 있습니다. 따라서 변수에 대한 접근을 제어하고 한번에 하나의 thread만 변수에 접근하는 것을 보장합니다. 위와 같은 간단한 읽기/쓰기를 수행하는 경우 이 솔루션이 좋습니다.

Note: 여러 thread에 의해 실행될 수 있는 lazy 변수들에게도 동일하게 private queue를 적용할 수 있습니다. 만약에 사용하지 않으면 lazy변수의 initializer에 2개의 인스턴스가 동작할 수 있습니다. 이전의 변수할당과 비슷하게 두 thread는 거의 동일한 시간에 동일한 lazy 변수에 액세스를 시도할 수 있습니다. 두번째 thread가 lazy 변수에 접근하려고 하면 아직 초기화되지 않았지만 첫번째 thread의 액세스에 의해 생성중입니다.

Thread barrier

  • 때로는 공유 resource의 getter 및 setter에서 간단한 변수 수정보다 더 복잡한 로직이 필요합니다. 온라인에서 이러한 질문들을 자주 볼 수 있으며 대부분의 해결책은 lock과 semaphore와 관련이 있습니다. locking은 매우 구현하기 어려워서 대신 애플의 dispatch barrier를 사용할 수 있습니다.
  • concurrent queue를 생성하면 동시에 실행할 수 있는 만큼 읽기 유형의 task를 처리할 수 있습니다. 변수에 기록해야 할 때, 이미 제출된 모든 항목이 완료될뿐만 아니라 업데이트가 끝날 때까지 새로운 제출이 실행되지 않도록 queue를 잠가야합니다.
  • 다음과 같은 방식으로 dispatch barrier를 구현합니다.
private let threadSafeCountQueue = DispatchQueue(label: "...", attributes: .concurrent)
private var _count = 0
public var count: Int {
  get {
    return threadSafeCountQueue.sync {
      return _count
    }
	}
	set {
    threadSafeCountQueue.async(flags: .barrier) { [unowned self] in
      self._count = newValue
		} 
	}
}

barrier에 부딪히게 되면 queue는 serial인것 처럼 가장하고 barrier task가 완료될때까지만 실행됩니다. barrier task가 완료되면 barrier task이후에 제출된 모든 task는 concurrent하게 실행될 수 있습니다.

Deadlock

  • 세마포어나 기타 명시적 잠금 메커니즘을 사용하지 않는 한, Swift programming에서 Deadlock은 드물게 일어납니다. 현재 dispatch queue에서 실수로 sync를 호출하는 경우가 가장 흔합니다.
  • 세마포어를 사용하여 여러 resource 에 대한 액세스를 제어한 경우 동일한 순서로 resource를 요청해야합니다. thread1이 해머와 톱을 요청하고, thread2가 톱과 해머를 요구한다면 교착상태가 될 수 있습니다. thread1이 해머를 요청하고 동시에 thread2가 요청하여 톱을 받습니다. 그러면 thread1이 해머를 풀지않고 톱을 요청하지만 thread2가 소유하고 있으므로 thread1은 기다려야합니다. 반대로 thread2는 톱을 요청하지만 thread1이 resource를 소유하고 있으므로 thread2가 톱을 사용가능할 때까지 기다려야합니다.요청한 resource가 해제될 때 까지 모두 진행되지 않으므로 두 thread는 이제 deadlock 상태에 있습니다.

Priority inversion

  • 우선순위 역전은 낮은 QoS를 가진 queue가 높은 QoS를 가진 queue보다 높은 시스템 우선순위를 가지게 되는 것입니다.
  • queue에 제출된 task에 따라 queue에 QoS를 변경할 수 있습니다. 일반적으로 작업을 queue에 제출하면 queue자체에 우선순위가 지정됩니다. 그러나 필요한 경우, 특정 task의 우선순위를 보통보다 높거나 낮게 지정할 수 있습니다.
    .userInitiated queue와 .utility queue를 사용하고 있고, 후자(.utility)에 .userInteractive QoS를 가진 (.userInitiated보다 우선순위가 높은)에 여러 task들을 제출하면 다음과 같은 상황이 발생할 수 있습니다. 운영체제에서 후자의 queue에 더 높은 우선 순위가 할당합니다. 갑자기 queue안에 있는 모든 task들(대부분 .utility QoS)이 .userInitiated작업보다 먼저 실행됩니다.
    → 이러한 것을 피하는 방법은 간단합니다. 더 높은 QoS가 필요하다면 다른 queue를 사용하면 됩니다.
  • 우선순위 역전이 발생하는 일반적인 상황은 높은 QoS를 가진 queue가 낮은 QoS를 가진 queue와 resource를 공유할 때 발생합니다. 낮은 QoS를 가진 queue가 객체의 lock을 획득했을 때 더 높은 QoS를 가진 queue는 기다려야합니다. lock이 해제될 때까지, 높은 우선순위를 가진 queue는 낮은 우선순위를 가진 queue가 작업하는 중에 아무작업도 하지 않고 멈춥니다.
let high = DispatchQueue.global(qos: .userInteractive)
let medium = DispatchQueue.global(qos: .userInitiated)
let low = DispatchQueue.global(qos: .background)

let semaphore = DispatchSemaphore(value: 1)

high.async {
    // Wait 2 seconds just to be sure all the other tasks have enqueued
    Thread.sleep(forTimeInterval: 2)
    semaphore.wait()
    defer { semaphore.signal() }
    print("High priority task is now running")
}

for i in 1 ... 10 {
    medium.async {
        let waitTime = Double(exactly: arc4random_uniform(7))!
        print("Running medium task \(i)")
        Thread.sleep(forTimeInterval: waitTime)
	} 
}

low.async {
    semaphore.wait()
    defer { semaphore.signal() }
    print("Running long, lowest priority task")
    Thread.sleep(forTimeInterval: 5)
}

/*
결과
Running medium task 7
Running medium task 6
Running medium task 1
Running medium task 4
Running medium task 2
Running medium task 8
Running medium task 5
Running medium task 3
Running medium task 9
Running medium task 10
Running long, lowest priority task
High priority task is now running
*/

출처: Concurrency by Tutorials Multithreading in Swift with GCD and Operations by Scott Grosch

profile
iOS시작~

0개의 댓글