멀티 쓰레드 환경에서 Race Condition에 대해서 알아보자

이번에 넥스터즈 면접 중 기술 면접을 진행하고 나서 내가 아직 많이 부족하다고 느낄 수 있었다.. Race Condition, Dead lock, Priority Inversion 등 멀티 스레드를 운용하면서 일어날 수 있는 문제점 등에 대해서 제대로 개념을 잡아두지 않아 답변에 어려움이 있었다..

 

처음엔 조금 속상했지만 그래도 이런 경험을 통해 이러한 키워드, 개념들을 잡고 갈 수 있는 기회가 될 수 있으니까 모르면 배우면 되지~ 하는 마음으로 단계별로 개념을 정리해보도록 하자.

 

먼저 오늘은 멀티쓰레드 환경에서 Race Condition에 대해서 알아보도록 하자!

Race Condition

그래서 레이스 컨디션이 뭘까 도대체 경쟁 상태라고 해석할 수 있는 이 상태는 두개 이상의 쓰레드나 프로세스가 공유 자원에 동시에 접근하면서 발생할 수 있는 문제를 말한다!

 

주로 개발하면서 결과가 실행 순서에 따라 달라질때 발생하는 문제로, iOS에서는 GCD 설정 즉 멀티쓰레딩 환경에서 발생하기 때문에 이 개념을 꼭 알고 있어야 한다.

 

간단한 예를 들어 쓰레드 A가 변수 count를 읽어서 1을 더하려고 하고, 동시에 쓰레드 B도 count를 읽어 1을 더하려고 할 때

두 쓰레드가 같은 변수 count를 동시에 읽는다면 업데이트된 값을 잃어버릴 가능성이 있다!!

 

Race Condition 예시

간단한 코드를 통해 알아보자

var count = 0

DispatchQueue.global().async {
	for _ in 0..<1000 {
		count += 1
	} 
}

DispatchQueue.global().async {
	for _ in 0..<1000 {
		count += 1
	}
}

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
	print("\\(count)")  // 예상 값은: 2000이지만 실제로는 예측할 수 없다.
}

이 과정에서 동시에 두개의 쓰레드에서 count에 접근해 1씩 올리는 작업을 수행하고 있는데 동시에 접근하다보니 읽기 → 계산 → 쓰기 의 과정에서 오류가 생겨 원하는 답을 얻지 못하게 된다고 생각하면 이해가 좀 쉽다!

 

왜 이런 문제가 발생하냐면 count += 1 은 3단계로 이뤄진다.

  1. count 값을 메모리에서 읽는다
  2. 읽은 값에 1을 더한다.
  3. 결과를 메모리에 쓴다.

이러한 3단계를 통해 진행되는데 두개가 동시에 접근하게 되면 이 3단계 진행 과정이 섞이거나 덮어쓰여지는 문제가 발생한다.

그래서 결론적으로 count의 값이 1000씩 두번 더한 2000이 되는게 아니라 예측할 수 없는 안정적이지 않은 상태가 된다는 말!!!!

 

Race Condition 해결 방법

그렇다면 이러한 Race Condition을 방지하기 위해선 어떤 해결 방법이 있을지 알아보자. 사실 생각해보면 간단하게 해결할 수 있다. 동시에 접근해서 오류가 생기는 것이기 때문에 동시에 접근하지 않도록 수정하면 되는 것 🙂

 

동시에 접근하지 않도록 수정하는 방법에 대해서 알아보자.

 

Serial Queue 사용하기

Race Condition을 방지하기 위해 Serial Queue를 사용하게 되면 한번에 하나의 작업만 실행되기 때문에 위 문제를 방지할 수 있다.

let serialQueue = DispatchQueue(label: "com.example.serialQueue")
var count = 0

DispatchQueue.global().async {
    for _ in 0..<1000 {
        serialQueue.sync {
            count += 1
        }
    }
}

DispatchQueue.global().async {
    for _ in 0..<1000 {
        serialQueue.sync {
            count += 1
        }
    }
}

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    print("Final count: \\(count)") // 항상 2000
}

sync 를 활용해 custom한 시리얼 큐를 작동 시키면 한 작업이 끝날때까지 하나의 작업은 기다리는 상태로 놓여질 수 있기 때문에 원하는 답변을 구할 수 있게 된다.

 

NSLock 사용

두번째로 NSLock을 사용해 작업 접근을 잠그고, 끝나면 해제하는 방식을 사용해 동시에 작업이 실행되는 방식을 컨트롤 해 오류를 방지하는 방법이다.

let lock = NSLock()
var count = 0

DispatchQueue.global().async {
    for _ in 0..<1000 {
        lock.lock()
        count += 1
        lock.unlock()
    }
}

DispatchQueue.global().async {
    for _ in 0..<1000 {
        lock.lock()
        count += 1
        lock.unlock()
    }
}

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    print("Final count: \\(count)") // 항상 2000
}

sync를 사용해 시리얼큐로 작동하는 것이 아닌 비동기로 작동을 굳이 하고싶다면 해당 하는 작업을 시작하기 전에 lock()을 통해 잠근 뒤 작업이 끝나면 unlock()을 실행해 다음 작업이 접근할 수 있도록 설정하는 방식으로 동시에 접근하는 방향을 예방할 수 있다.

 

이제 그럼 UI를 업데이트하는 과정에서 어떤 문제가 발생할 수 있는지 알아보자.

 

UI 업데이트는 반드시 메인쓰레드에서 실행되어야 한다는 점은 다들 알고 있을 것이다. 이 과정에서 여러 쓰레드가 동시에 UI를 변경하려고 하면 충돌이 발생할 수 있다.

DispatchQueue.global().async {
	myLabel.text = "Updated Text"
} // 충돌 가능
DispatchQueue.global().async {
	DispatchQueue.main.async {
		myLabel.text = "Updated Text"
	}
} // 메인스레드 실행으로 오류 해결


Race Condition을 생각해야하는 이유

Race Condition은 중요 데이터가 덮어쓰여서 손실이 날 수 있고 테스트 환경에서는 잘 동작하지만 실제 환경에서는 예측할 수 없는 오류가 발생할 수 있다.

 

또한 이에 대한 디버깅에 어려움이 있기 떄문에 꼭 생각하면서 멀티스레드를 구성해야한다.

 

작은 목 데이터로 테스트를 진행하다가 가끔 실제 환경에서 알 수 없는 오류가 일어나는 경우가 있었는데 이런 경우 모든 순간이 다 Race Condition인 상태라서 발생했다고 볼 순 없지만 이 개념을 이해하고 있으면 빠르게 접근해 해당 오류 여부를 판단할 수 있는 능력을 길러야한다고 생각하면서 확실하게 개념을 잡은 것 같아 이제라도 해당 내용을 꼭 숙지하고 문제가 발생하면 해결할 수 있는 능력을 길러나가야겠다 🙂

 

이래서 어디든 면접을 볼 기회가 있다면 다 보는게 좋다고 하는 것 같다. 바라보는 시야가 넓어질 수 있는 기회가 있는 느낌..! 다음엔 Dead Lock에 대해서 알아볼 예정이다.

 

오늘은 여기까지!!