평안하자

[실용주의 프로그래머] 동시성 본문

Review

[실용주의 프로그래머] 동시성

eeeeerrr 2024. 3. 4. 09:06

동시성과 병렬성 처리는 필수!

동시성 (Concurrency)

둘 이상의 코드 조각이 실행될 때 동시에 실행중인 것처럼 행동하는 것 (병행성)

동시성을 얻으려면 실행 중에 코드의 다른 부분으로 실행을 전환할 수 있는 환경에서 코드를 구동해야 한다.

보통은 파이버(fiber;운영체제의 스케줄러를 사용하지 않는 사용자 수준 스레드), 스레드, 프로세스 등을 사용하여 동시성을 구현한다.

 

병렬성 (Parallelism)

둘 이상의 코드 조각이 실행될 때 실제로 동시에 실행되는 것

병렬성을 얻으려면 두 가지 일을 동시에 할 수 있는 하드웨어가 필요하다.

CPU 하나에 있는 여러 개의 코어일 수도 있고, 컴퓨터 한 대에 있는 여러 CPU이거나 아니면 네트워크로 연결된 여러 대의 컴퓨터일 수도 있다.

 

모든 일에는 동시성이 있다

시스템 규모가 어느 정도를 넘어가고 실제 세상을 다루는 애플리케이션에서 동시성은 필수다.

동시성이 겉으로 드러날 때도 있지만 라이브러리 안에 묻혀 있는 경우도 있다.

 

이 장에서 다룰 것

1. 시간적 결합 찾고 깨트리기

시간적 결합은 당면한 문제 해결에 꼭 필요하지 않은 일 처리 순서를 코드가 강제할 때 생긴다.

의존성을 줄이고 유연해지려면 이 시간적 결합을 찾고 깨트려야 한다.

2. 공유상태는 틀린 상태

단순히 전역 변수만을 이야기하는 것이 아니다. 

둘 이상의 코드 뭉치가 하나의 변경 가능한 데이터를 참조하고 있다면 공유상태가 존재하는 것이다. 

우회 방법을 설명하나 결국에는 모두 잘못되기 쉬우니 3,4번 참고하자.

3. 액터(actor) 모델과 프로세스

프로세스들이 독립적으로 수행되며 서로 데이터를 공유하지 않는다. 대신 채널을 통해 잘 정의된 단순한 의미론을 사용하여 의사소통하는 접근 방법의 이론과 설계

4. 칠판

객체 저장소와 똑똑한 게시-구독 중개자(broker)를 합한 것처럼 동작하는 시스템

칠판과 유사한 방식으로 동작하는 미들웨어 계층 구현으로 시간적 결합을 대폭 줄일 수 있다.

 

 

Topic 33. 시간적 결합 깨트리기

시간적 결합(temporal coupling)이란 무엇인가?

소프트웨어 설계 요소로서 시간의 역할

1. 동시성 (동시에 일어나는 일들)

2. 순서 (시간의 흐름 속에서 일들의 상대적인 위치)

 

직선적 사고(순차적 사고)는 유연하지 않은 시간적 결합을 만들게 된다.

  • 메서드 A는 언제나 반드시 메서드 B보다 먼저 호출해야 한다.
  • 보고서는 한번에 오직 하나만 생성할 수 있다.
  • 버튼 클릭을 처리하려면 먼저 화면이 갱신되어야 한다.
  • '똑'은 '딱'보다 먼저 일어나야 한다.

동시성 찾기

TIP 작업 흐름 분석으로 동시성을 개선하라.

 

활동 다이어그램을 이용하여 화살표와 동기화 막대(synchronization bar)를 살펴보자. 

자기에게 오는 화살표가 없는 활동은 언제든지 시작할 수 있다. 동기화 막대로 들어오는 활동이 모두 완료된 후에야 막대에서 나가는 화살표를 따라 진행할 수 있다.

활동 다이어그램을 사용하면 동시에 수행할 수 있는데도 아직 동시에 하고 있지 않은 활동들을 찾아내서 병렬성을 극대화할 수 있다.

 

동시작업의 기회

활동 다이어그램은 동시에 작업할 수 있는 부분들을 보여준다. 하지만 진짜로 동시에 하는 것이 좋은지는 알려주지 않는다.

그래서 설계가 필요하다. 

우리는 시간이 걸리지만, 우리 코드가 아닌 곳에서 시간이 걸리는 활동을 찾고 싶다. 

데이터베이스를 조회할 때, 외부 서비스에 접근할 때, 사용자 입력을 기다릴 때와 같이 우리 프로그램이 다른 작업이 끝나기를 기다려야 하는 상황을 주목하자. 

이런 순간에 바로 CPU가 아무 일도 하지 않은채 기다리는 것이 아니라 좀 더 생산적인 일을 할 수 있도록 설계해야 한다.

 

병렬작업의 기회

동시성은 소프트웨어 동작 방식, 병렬성은 하드웨어가 하는 것!

 

컴퓨터 한 대이든 여러 대이든 우리에게 여러 개의 프로세서가 있다면, 그리고 작업을 프로세서들에게 나누어 줄 수 있다면 전체 소요 시간을 단축할 수 있다!

 

가장 이상적인 것은 비교적 독립적인 부분 작업들을 쪼개서 각각 병렬로 처리한 다음, 결과를 합치는 것이다. ex. 엘릭서의 컴파일러

 

기회를 찾아 내는 것은 쉽다

동시 작업이나 병렬 작업을 해서 이득을 보는 부분을 애플리케이션에서 찾아보자. 

이제 어떻게 안전하게 구현할 수 있을까?

 

 

Topic 34. 공유 상태는 틀린 상태

공유상태 예시

  • 애플파이가 하나 남아있을 때, 두 고객이 애플파이가 남아있냐고 물었다. 두 종업원은 각각 같은 애플파이를 바라보며 주문이 가능하다고 했을 때 두 고객 중 한명은 실망하게 될 것이다.
    • 문제: 종업원들은 서로를 고려하지 않고 진열장만 동시에 (병렬적으로) 확인했다. 
  • 여러 명이 사용하는 공동 은행 계좌에서 물건 하나만 살 수 있는 돈밖에 없다고 하자. 두 사람이 물건 하나를 사기로 동시에 결정했다. 카드 결제 단말기에서 두 명 모두에게 결제가 가능하다고 한다면 가게와 은행, 사람이든 분명 누군가는 잘못된 정보에 기분이 나빠질 것이다. 
    • 문제: 카드 결제 단말기들도 서로를 고려하지 않고 은행 잔고를 확인했다.

문제는?

상태가 공유된 것이다.

 

두 프로세스가 같은 메모리 영역에 쓰기가 가능하다는 점이 문제가 아니다.

문제는 어느 프로세스도 자신이 보는 메모리가 일관되어 있음을 보장할 수 없다는 점이다. 

 

만약 진열장/계좌 등 공유하고 있는 상태 값이 바뀐다면 결정에 사용한 메모리는 시효가 지난 것이다. 

값을 가져오고 갱신하는 동작이 원자적이지 않기 때문에 실제 값이 그 사이에 바뀔 수 있는 것이다.

 

그렇다면 어떻게 원자적으로 바꿀 수 있을까??

 

원자적 연산이란? (블로그 작성글 참고)
https://affectionatedevlog.tistory.com/23
 

원자적 연산이란?

실용주의 프로그래머와 동시성 관련된 공부를 하면서 원자적 연산에 대해 개념적으로 조금 헷갈리는 부분이 있어 별도로 빼서 간단히 정리해보았다. 트랜잭션, 락, 등 더 할 얘기는 많지만 이정

affectionatedevlog.tistory.com

 

 

세마포어 및 다른 상호 배제 방법

세마포어(semaphore)를 만들어서 다른 리소스의 사용을 제어하는 데 쓸 수 있다. 진열장/계좌의 내용물을 바꾸고 싶은 사람은 세마포어를 소유하고 있을 때만 바꿀 수 있다. 전통적으로 세마포어를 획득하는 작업을 'P', 반환하는 작업을 'V'로 불렀지만 요즘은 잠금(lock)/잠금해제(unlock), 획득(claim)/반환(release) 등으로 부른다.

 

이러한 방식은 lock을 통해 공유 상태 문제를 해결할 수 있지만 몇 가지 문제가 있다. 

가장 큰 문제는 접근하는 모든 사람/요청이 빠짐없이 세마포어를 사용해야만 제대로 동작한다는 것이다. 만약 어떤 개발자가 약속을 지키지 않는 코드를 쓴다면 다시 혼돈에 빠진다.

 

리소스를 트랜잭션으로 관리하라

문제 1.

위의 설계가 미흡한 것은 진열장/계좌 등의 공유상태 사용을 보호할 책임을 이를 사용하는 사람에게 전가하기 때문이다.

해결 1.

제어를 중앙으로 집중시키자. 그러려면 API를 바꿔서 하나의 호출로 조회 및 갱신되도록 해야 한다.


문제 2. 

리소스 접근을 메서드 한 곳으로 모아도 이 메서드 자체도 여러 개의 스레드에서 동시에 호출될 수 있다.

해결 2.

이 메서드또한 여전히 세마포어로 보호해야 한다. 또한 예외가 발생했을 경우 세마포어 잠금이 풀리는지 꼭 확인해야 한다. 

-> 이러한 실수가 흔하다보니 많은 언어에서 이런 상황을 처리해주는 라이브러리를 제공하는 경우가 많다.


문제 3.

공유하는 리소스가 여러 개일 때에도 문제가 발생했을 때 리소스를 계속 차지하지 않고 반환하도록 예외처리를 해주어야 한다. 이때 자체적으로 try-catch 등으로 예외처리를 해주어야 하는데, 코드가 정말 지저분하고 무슨 일을 하는지 알아보기 힘든 경우가 많다. 

해결 3.

<실용주의적인 접근 방법>
이 코드를 새로운 모듈로 옮기고, 클라이언트는 리소스 조합만을 요청할 수 있어야 한다. 또한 결과(반환값)는 성공 아니면 실패뿐이다.

실제 상황에서는 이런 조합 메뉴가 더 많이 있을 것이므로, 메뉴마다 새로운 모듈을 만드는 대신 더 나은 방법을 제안한다.

자신의 구성 요소에 대한 참조들을 갖고 있는 메뉴 항목(menu item)이 있고, 또 그 구성 요소 각각이랑 자원을 주고 받는 춤을 추는 일반화된 get_menu_item 메서드가 있는 편이 아마 더 나을 것이다. 

이 부분 공부/고민해보기

 

 

트랜잭션이 없는 갱신

불규칙한 실패는 동시성 문제인 경우가 많다.

 

공유 메모리는 동시성 문제의 원인으로 많이 지목받는다. 하지만 사실 수정 가능한 리소스를 공유하는 애플리케이션 코드 어디에서나 동시성 문제가 발생할 수 있다. 여러분 코드의 인스턴스 둘 이상이 파일, 데이터베이스, 외부 서비스 등 어떤 리소스에 동시에 접근할 수 있다면 여러분은 잠재적인 문제를 안고 있는 것이다.

 

가끔은 이런 리소스가 명확하지 않을 수 있으므로 문제를 좁혀나가면서 해결해봐야 한다. 

 

예시)
책 빌드 시간을 줄이려고 스레드를 사용하여 빌드 도구들을 더 많이 병렬화한 경우 빌드가 실패하는 문제
매번 다른 곳에서 이상한 방식으로 실패 (불규칙적인 실패)

오류가 발생한 경우는 항상 실제로는 제 위치에 파일이 있는 경우에도 불구하고 스레드가 파일이나 디렉터리를 찾지 못한 경우라는 것을 발견. 또한 임시로 현재 작업 디렉터리를 바꾸는 코드(수정 가능한 리소스)가 몇 군데 있었음.

병렬 처리를 하지 않았을 때는 작업 디렉토리를 다시 원래 위치로 바꾸는 것으로 충분했으나, 병렬로 처리하면서 한 스레드가 작업 디렉터리를 바꿔서 임시 작업 디렉터리에 있는 도중에 다른 스레드가 작동을 시작할 수 있어 생기는 문제였음. 이 스레드는 작업 디렉터리가 원래 디렉터리일 것이라고 가정했지만, 스레드들은 현재 작업 디렉터리 값을 공유하므로 그 가정이 어긋나게 되면서 생기는 문제였음.

 

 

그 밖의 독점적인 접근

대부분의 언어에는 공유 리소스에 독점적으로 접근하는 것을 도와주는 라이브러리가 있다. 상호 배제를 의미하는 뮤텍스, 모니터나 세마포어라고 부르기도 한다. 

 

언어 자체에 동시성 지원이 들어있는 언어도 있다. 러스트(Rust)는 데이터의 소유권이라는 개념을 강제하여 변경 가능한 데이터 조각은 어느 한 시점에 단 하나의 변수나 매개 변수만 참조를 가질 수 있다.

 

함수형 언어들은 모든 데이터를 변경 불가능하게 만드는 경향이 있으므로 동시성 문제를 단순하게 만든다고 생각할 수 있지만, 언젠가는 모든 것이 변경 가능한 진짜 세상에 발을 들여야 하므로 똑같은 문제를 겪는 순간이 올 것이다.

 

의사 선생님, 아파요...

리소스를 공유하는 환경에서 동시성은 어렵다.

하지만 이 문제를 직접 풀려고 한다면 고난의 연속이다. 

이어지는 <Topic 35 액터와 프로세스>, <Topic 36 칠판>에서는 이런 고통 없이 동시성의 이득을 취할 수 있는 대안을 제시한다.