병렬화, 병렬 프로그래밍 #2
병렬화는 컴퓨터 공학에서 매우 중요한 기법으로 여러 작업을 동시에 처리하여 계산 속도를 높이는 기법입니다. 프로젝트를 진행하며 병렬화에 대해 공부해 나갔고 이를 기록하는 김에 글을 작성하게 됐습니다. 이번에는 병렬화에서 문제가 되는 것들(레이스 컨디션, 데드락, 오버헤드 등)을 방지하기 위한 방법과 실제 예시로 몇가지 보여드릴 예정입니다. (본인 공부 및 기록용)😁
병렬화(parallelism)란?
병렬화(parallelism)란 여러 작업을 동시에 처리하여 계산 속도를 높이는 기법입니다. 컴퓨터 과학에서 병렬화는 크게 2가지 방식으로 구현될 수 있습니다.
참고 : Windows-BALM
제 깃허브에 올린 BALM을 윈도우 환경에서 돌아가게 한 코드이며 병렬화 코딩을 통해 최적화를 시켰으며 처리 시간 성능을 원본 코드 대비 30퍼정도 향상시켰습니다. 참고하시면 좋을 거 같습니다.
병렬화에서 문제가 될 수 있는 것들
데드락(Deadlock)
자주 들어보셨을 단어입니다. 데드락은 병렬 시스템에서 여러 프로세스가 서로의 지원을 기다리면서 영원히 대기 상태에 빠지는 상황을 말합니다.
이런 상황을 피하기 위해서는 자원 할당 순서를 정하거나 타임아웃을 설정하는 등의 조치가 필요합니다.
간단히 코드로 보여드리면
import threading
# 두 개의 락 객체 생성
lock1 = threading.Lock()
lock2 = threading.Lock()
def thread1():
lock1.acquire() # lock1 점유
print("Thread 1: Acquired lock 1, waiting for lock 2...")
lock2.acquire() # lock2 점유 시도
print("Thread 1: Acquired lock 2")
lock2.release()
lock1.release()
def thread2():
lock2.acquire() # lock2 점유
print("Thread 2: Acquired lock 2, waiting for lock 1...")
lock1.acquire() # lock1 점유 시도
print("Thread 2: Acquired lock 1")
lock1.release()
lock2.release()
# 두 개의 스레드 생성
t1 = threading.Thread(target=thread1)
t2 = threading.Thread(target=thread2)
t1.start()
t2.start()
t1.join()
t2.join()
위 코드와 같이 ‘thread1’은 ‘lock1’을 점유한 후 ‘lock2’를 기다리고, ‘thread2’는 ‘lock2’를 점유한 후 ‘lock1’을 기다립니다. 이 상태에서 두 스레드는 서로의 자원을 무한히 기다리게 되어 데드락이 발생합니다.
실 예시로 들면 두 자동차가 좁은 교차로에서 서로 상대방이 먼저 지나가기를 기다리며 멈춰있는 상황이라 생각하시면 됩니다.
오버 헤드(Overhead)
이 단어도 그 다음으로 많이 들어보셨을 거 같은데 병렬화를 구현할 때, 추가적인 비용이 발생하는데, 이를 오버헤드라 합니다. 어떠한 프로그램을 설계할 때 오버헤드를 최소화할 수 있도록 신중하게 설계해야합니다.
오버헤드는 주로 아래와 같은 원인들로 발생합니다.
-
스레드/프로세스 생성 및 관리: 새로운 스레드나 프로세스를 생성하고 종료하는 데 드는 시간과 자원이 오버헤드로 작용합니다. 이를 줄이기 위해, 스레드 풀이나 프로세스 풀을 활용하여 필요한 만큼만 생성하고 재사용하는 방식이 권장됩니다.
-
데이터 분할: 작업을 병렬로 처리하기 위해 데이터를 나누고 분배하는 과정에서 추가적인 계산과 자원 소모가 발생합니다. 특히 데이터가 매우 크거나 복잡할 경우 이 오버헤드가 커질 수 있습니다.
-
통신 오버헤드: 병렬 처리 간에 데이터를 주고받기 위한 통신이 필요한 경우, 이 통신으로 인한 지연과 자원 소모도 오버헤드로 작용합니다. 네트워크 기반 병렬 처리에서는 이 오버헤드가 더욱 두드러질 수 있습니다.
-
동기화 오버헤드: 병렬로 실행되는 작업들이 공통 자원을 사용할 때, 데이터의 일관성을 유지하기 위해 동기화 작업이 필요합니다. 이 동기화 과정에서의 대기 시간이나 잠금 관리가 성능에 부정적인 영향을 줄 수 있습니다.
Race Condition
경쟁 상태(Race Condition)은 여러 스레드가 동시에 동일한 자원에 접근하려 할 때 발생할 수 있습니다.
이를 해결하기 위해 뮤텍스(Mutex)나 세마포어(Semaphore)와 같은 동기화 메커니즘을 사용하여 자원의 접근을 제어해야 합니다.
동기화 문제(Synchronization Issues)
병렬화에서는 여러 프로세스나 스레드 간의 데이터 공유와 동기화가 필수적입니다. 그러나 이를 제대로 관리하지 않으면 데이터의 일관성이 깨질 수 있습니다. 예를 들어, 두 스레드가 동일한 리스트에 동시에 접근하여 삽입 작업을 수행할 때, 리스트의 순서가 꼬이거나 중복이 발생할 수 있습니다. 이를 방지하기 위해서는 락(Lock)을 사용하여 데이터 접근을 순차적으로 처리하게 하거나, 원자적 연산(Atomic Operation)을 이용하여 일관성을 유지할 수 있습니다.
복잡성 증가
병렬화된 시스템은 단일 스레드 시스템보다 훨씬 복잡합니다. 또한 설계를 잘못하여 병렬화해서는 안될 작업을 병렬화하다 다른 결과를 도출할 수도 있습니다. 각 스레드 간의 상호작용, 자원 공유, 동기화 문제 등을 고려해야 하므로 코드의 복잡도가 증가합니다. 이로 인해 버그 발생 가능성이 높아지고, 디버깅과 유지보수도 어려워집니다.
병렬화를 설계할 때는 이러한 복잡성을 관리할 수 있는 구조적 접근이 필요합니다.
해결방법
데드락(Deadlock)
-
예방(Prevention): 데드락 발생 조건 중 하나 이상을 충족하지 않도록 설계합니다. 예를 들어, 자원 요청 시 한꺼번에 모든 자원을 요청하게 하거나, 순환 대기를 피하기 위해 자원에 순서를 부여합니다.
-
회피(Avoidance): 시스템이 데드락 상태로 진입하지 않도록, 자원 할당 시 미리 데드락 발생 가능성을 계산하여 안전한 자원 할당만 허용합니다. 대표적인 방법으로는 은행가 알고리즘(Banker’s Algorithm)이 있습니다.
-
탐지(Detection): 데드락 발생 여부를 주기적으로 확인하고, 데드락이 발생하면 일부 프로세스를 강제로 종료하거나 자원을 회수하여 데드락을 해소합니다.
-
회복(Recovery): 데드락이 발생한 경우, 일부 프로세스를 종료하거나 자원을 강제로 회수하여 데드락 상태를 해소합니다.
오버헤드(Overhead)
-
최소화된 스레드 사용: 필요 이상의 스레드나 프로세스를 생성하지 않고, 적절한 개수로 제한합니다.
-
데이터 분할 최적화: 데이터를 분할할 때 작업 부하가 균등하게 분배되도록 신경 씁니다.
-
효율적인 통신 프로토콜 사용: 데이터 전송을 최적화하고 불필요한 통신을 줄입니다.
-
비동기적 접근: 가능하다면 비동기적으로 자원에 접근하여 동기화에 따른 오버헤드를 줄입니다.
결론
이상적인 경우만 생각해보면 똑같은 성능을 더 빠른 시간내에 처리한다고 병렬화를 마구잡이로 오용할 수도 있습니다. 항상 적절한 스레드를 관리하고 작업이 병렬화로 진행을 해도 되는지 정확히 판단 후에 사용하면 좋을 거 같습니다.
이것저것 공부하면서 관련 내용에 대해 계속 추가할 예정입니다. 궁금한 것들이나 추가 및 수정했으면 좋겠는 거 말해주시면 좋을 거 같아요. 좋은 하루 보내시길 바래요 :)
댓글남기기