사전 메시지 전달
개념

메시지 전달

gabury1고친 사람 github-actions[bot]

메시지 전달은 동시에 도는 작업들이 한 값을 같이 고치지 않고 값을 담은 메시지를 서로 보내 주게 합니다. 작업은 스레드나 고루틴처럼 따로 도는 실행 흐름입니다. 한 작업은 다른 작업의 메모리를 건드리지 않습니다. 객체 지향에서는 같은 이름이 메서드를 부르는 일을 가리키기도 합니다.

쉽고 빠른 이해

무슨 일을 하는 물건인가 — 여러 작업이 한 변수를 같이 만지지 않고 값을 서로 보내 주게 합니다. 주문 작업이 「재고 3개 빼 주세요」를 보내면 재고 작업이 받아서 재고를 줄입니다. 재고 숫자는 재고 작업만 고칩니다.

왜 이렇게 하나 — 두 작업이 한 변수를 동시에 고치면 한쪽이 쓴 값을 다른 쪽이 덮어씁니다. 이를 막는 잠금은 한 곳만 빠뜨려도 소용이 없습니다. 값마다 주인을 하나로 두면 같이 고칠 일이 처음부터 안 생깁니다. 서버끼리는 메모리를 나눠 쓸 수 없어서 보내는 것 말고는 길이 없습니다.

어떻게 도나

  1. 보내는 작업이 값을 메시지에 담아 보냅니다
  2. 메시지는 받는 작업 앞의 대기 줄에 들어갑니다
  3. 받는 작업이 메시지를 하나씩 꺼내 차례로 처리합니다

대가 — 값을 담아 보내는 만큼 복사와 기다림이 생깁니다. 받는 쪽이 느리면 메시지가 쌓입니다. 여러 작업이 한 값을 자주 읽고 조금씩 고치는 일에는 잠금이 낫습니다. 읽을 때마다 주인에게 물어 답을 기다리는 비용이 잠금보다 크기 때문입니다.

상세

사무실 두 사람이 화이트보드 한 장에 적힌 재고 숫자를 같이 고친다고 해 봅니다. 한 사람이 지우는 사이 다른 사람이 새 숫자를 적으면 한쪽 계산이 사라집니다. 그래서 보드는 한 사람만 고치기로 합니다. 다른 사람은 쪽지를 써서 그 사람 책상의 우편함에 넣습니다.

이 쪽지 방식이 프로그램 안에서 어떻게 도는지 차례로 봅니다.

같이 고치면 생기는 일

스레드는 한 프로그램 안에서 따로 도는 실행 흐름입니다. 한 프로그램의 스레드들은 같은 메모리를 봅니다.

한 스레드가 변수에 쓴 값을 다른 스레드가 곧바로 읽을 수 있습니다. 이렇게 같은 메모리를 보면서 값을 주고받는 방식을 공유 메모리 방식이라고 부릅니다.

이 편에서는 스레드처럼 따로 도는 실행 흐름을 통틀어 작업이라고 부릅니다. 스레드일 수도, 뒤에 나올 고루틴일 수도 있습니다.

공유 메모리 방식에는 함정이 있습니다. 두 작업이 재고 변수를 동시에 읽고 1씩 줄이면 둘 다 같은 옛 값을 읽을 수 있습니다. 그러면 두 번 줄어야 할 재고가 한 번만 줄어듭니다. 실행 순서에 따라 결과가 바뀌는 이런 오류를 경쟁 상태라고 합니다.

흔한 해법은 잠금입니다. 잠금은 한 번에 한 작업만 그 변수를 만지게 막습니다. 잠금은 그 변수를 만지는 코드마다 걸어야 합니다. 한 곳만 빠뜨려도 경쟁 상태가 살아납니다.

한 번에 한 작업만 들여보내는 이 성질을 상호 배제라고 합니다. 잠금은 상호 배제를 이루는 가장 흔한 수단입니다.

값마다 주인을 하나로 두는 방식

메시지 전달은 다른 길을 갑니다. 값마다 그 값을 맡을 작업을 하나로 정합니다. 이 작업이 그 값의 주인입니다. 다른 작업은 그 값을 직접 만지지 않습니다.

값을 바꾸고 싶은 작업은 바꿀 내용을 메시지에 담아 주인에게 보냅니다. 값을 읽을 때도 주인에게 물어야 합니다. 주인이 값을 담아 답을 보내 줍니다.

메시지는 보내는 쪽이 받는 쪽에 넘기는 데이터 한 덩이입니다. 「재고에서 3을 빼라」 같은 요청이 메시지가 됩니다. 계산을 마친 결과도 메시지가 됩니다. 값을 고치는 작업이 하나뿐이니 그 값에는 잠금이 필요 없습니다.

주문 작업과 반품 작업이 둘 다 재고를 고쳐야 하는 경우를 그림으로 봅니다. 위쪽이 공유 메모리 방식입니다. 아래쪽이 메시지 전달 방식입니다.

flowchart TD
    subgraph S["공유 메모리 방식"]
        A1["주문 작업"] -->|직접 고친다| V1["재고 변수"]
        B1["반품 작업"] -->|직접 고친다| V1
    end
    subgraph M["메시지 전달 방식"]
        A2["주문 작업"] -->|3을 빼라| Q["재고 작업"]
        B2["반품 작업"] -->|2를 더하라| Q
        Q -->|혼자 고친다| V2["재고 변수"]
    end

위쪽에서는 두 작업이 재고 변수에 직접 손을 댑니다. 아래쪽에서는 재고 변수에 닿는 화살표가 재고 작업 하나에서만 나옵니다. 주문 작업과 반품 작업은 재고 작업에 메시지만 보냅니다.

보내기와 받기

메시지 전달의 기본 동작은 둘입니다. 보내기(send)는 메시지를 상대에게 넘깁니다. 받기(receive)는 도착한 메시지를 하나 꺼냅니다.

받는 작업은 메시지를 한 번에 하나씩 처리합니다. 메시지가 동시에 여럿 와도 처리는 차례로 합니다. 받는 작업 안에서는 두 일이 겹쳐 돌지 않으므로 자기 값을 고칠 때 잠금이 필요 없습니다.

메시지에 담는 값은 대개 복사본입니다. 복사하지 않고 넘길 때는 보낸 쪽이 그 값을 더 만지지 않기로 약속합니다. 보낸 쪽이 넘긴 값을 계속 고치면 결국 두 작업이 한 값을 만지게 됩니다. 그러면 경쟁 상태가 다시 생깁니다.

기다리는 방식 두 가지

메시지를 보낸 뒤 보내는 쪽이 멈추느냐 안 멈추느냐로 방식이 갈립니다.

동기 방식에서는 보내는 쪽이 받는 쪽이 꺼낼 때까지 멈춥니다. 두 쪽이 만나는 순간 메시지가 건너갑니다.

메시지가 건너간 순간 보내는 쪽은 받는 쪽이 받기 지점까지 와 있다는 것을 압니다. 받는 쪽도 보내는 쪽이 보내기 지점까지 와 있다는 것을 압니다. 그래서 동기 방식은 값을 나르는 일과 두 작업의 진행 순서를 맞추는 일을 한 번에 합니다.

비동기 방식에서는 보내는 쪽이 메시지를 넣어 두고 바로 다음 일로 갑니다. 보내는 쪽이 받는 쪽의 속도에 묶이지 않습니다.

넣어 둔 메시지는 버퍼에 쌓입니다. 버퍼는 메시지를 잠깐 담아 두는 줄입니다. 받는 쪽은 차례가 오면 버퍼에서 꺼냅니다.

sequenceDiagram
    participant S as 보내는 작업
    participant B as 버퍼
    participant R as 받는 작업
    S->>B: 메시지 1을 넣는다
    S->>B: 메시지 2를 넣는다
    Note over S: 기다리지 않고 다음 일을 한다
    R->>B: 꺼낸다
    B-->>R: 메시지 1
    R->>B: 꺼낸다
    B-->>R: 메시지 2

그림에서 보내는 작업은 받는 작업이 오기 전에 두 메시지를 넣고 떠났습니다. 받는 작업은 넣은 순서대로 꺼냅니다.

버퍼에 한도를 두면 가득 찼을 때 보내는 쪽도 기다립니다. 한도를 안 두면 받는 쪽이 느릴 때 메시지가 끝없이 쌓여 메모리를 다 씁니다. 받는 쪽이 밀릴 때 보내는 쪽을 늦추는 장치를 백프레셔라고 부릅니다.

받는 쪽을 가리키는 방식 두 가지

메시지를 누구에게 보내는지 적는 방식도 둘입니다. 받을 작업에게 직접 보내는 방식과 중간의 통로에 넣는 방식입니다.

메일박스는 작업마다 하나씩 딸린 받은 편지함입니다. 보내는 쪽은 받을 작업을 가리켜 그 메일박스로 보냅니다.

모든 계산을 메일박스를 가진 작업끼리 주고받는 메시지로 짜는 모형이 액터 모델입니다. Erlang 은 이 모형을 언어의 기본 동작으로 넣은 언어입니다.

채널은 작업과 따로 서 있는 통로입니다. 보내는 쪽은 채널에 넣습니다. 받는 쪽은 채널에서 꺼냅니다. 두 쪽은 반대편에 누가 있는지 몰라도 됩니다.

보내는 쪽과 받는 쪽이 통로만 함께 쓰는 이 구조로 동시성을 설명하는 이론이 CSP(Communicating Sequential Processes, 통신하는 순차 프로세스)입니다. Go 의 채널이 이 생각을 따릅니다.

Go 코드로 보는 재고 작업

앞 그림의 재고 작업을 Go 코드로 만들어 봅니다. 고루틴은 Go 런타임이 관리하는 실행 흐름입니다.

고루틴은 스레드보다 훨씬 적은 메모리로 만들 수 있습니다. 함수 앞에 go 를 붙이면 그 함수가 새 고루틴에서 돕니다.

Go 의 채널은 make 로 만듭니다. make(chan int) 는 정수를 나르는 채널입니다. ch <- 3 은 채널에 3을 넣습니다. <-ch 는 채널에서 값을 하나 꺼냅니다.

Go
orders := make(chan int)
done := make(chan int)

go func() {
    stock := 10
    for n := range orders {
        stock -= n
    }
    done <- stock
}()

orders <- 3
orders <- 2
close(orders)
fmt.Println(<-done) // 5

재고 stock 은 새 고루틴 안에서만 고쳐집니다. 바깥 코드는 orders 채널로 3과 2를 보낼 뿐입니다.

close 는 더 보낼 메시지가 없다는 신호입니다. for n := range orders 는 채널에서 값을 하나씩 꺼냅니다. 채널이 닫히고 빌 때까지 돕니다.

반복이 끝나면 고루틴은 남은 재고를 done 채널로 돌려보냅니다. 바깥 코드가 그 값을 꺼내 찍습니다. 10에서 3과 2를 뺀 5가 나옵니다. 이 코드에는 잠금이 하나도 없습니다.

make(chan int) 처럼 칸 수를 안 주면 앞에서 본 동기 방식입니다. orders <- 3 은 고루틴이 그 값을 꺼낼 때까지 멈춥니다. make(chan int, 10) 처럼 칸 수를 주면 그만큼 버퍼를 둔 비동기 방식이 됩니다.

자바라면 BlockingQueue 로 같은 꼴을 만듭니다. 재고 스레드 하나만 큐에서 꺼내 재고를 고칩니다. 다른 스레드는 큐에 넣기만 합니다.

서버 사이의 메시지 전달

프로세스는 실행 중인 프로그램 하나입니다. 프로세스마다 자기 메모리를 따로 가집니다.

서로 다른 컴퓨터에서 도는 프로세스라면 메모리를 나눠 쓸 길이 아예 없습니다. 여러 컴퓨터가 네트워크로 묶여 한 일을 하는 체계를 분산 시스템이라고 합니다. 여기서는 메시지를 보내는 것이 서로 협력하는 유일한 길입니다. 서비스끼리 주고받는 요청과 응답도 메시지 전달의 한 모습입니다.

네트워크를 건너는 메시지는 한 프로그램 안보다 다루기 어렵습니다. 늦게 도착하기도 합니다. 도중에 사라지기도 합니다. 보낸 순서와 다른 순서로 도착하기도 합니다.

순서가 뒤바뀌어 도착하는 문제는 메시지에 정보를 덧붙여 풉니다. 그 대표가 램포트 시계입니다. 램포트 시계는 프로세스마다 숫자 하나를 둡니다. 메시지를 보내거나 받는 일처럼 프로세스에서 일이 하나 일어날 때마다 그 숫자를 1씩 올립니다.

메시지를 보낼 때는 지금 숫자를 메시지에 붙입니다. 받는 쪽은 자기 숫자를 붙어 온 숫자보다 크게 올립니다. 그러면 보낸 일의 숫자가 그 메시지를 받은 일의 숫자보다 늘 작습니다. 도착 순서가 뒤섞여도 이 숫자를 견주면 서로 다른 프로세스에서 일어난 일의 앞뒤를 맞출 수 있습니다.

서비스 사이에서 메시지를 맡아 두었다가 넘겨 주는 중개 서버도 있습니다. 이것이 메시지 큐입니다. 받는 서비스가 잠시 멈춰 있어도 보내는 서비스는 큐에 넣고 다음 일로 갑니다.

한 컴퓨터 안의 프로세스끼리도 메시지를 씁니다. 프로세스끼리 데이터를 주고받는 일을 프로세스 간 통신이라고 부릅니다. 이 통신도 공유 메모리와 메시지 전달 두 갈래로 나뉩니다. 메시지 전달 쪽에서는 운영체제가 보내기와 받기를 시스템 콜로 내줍니다.

여러 컴퓨터가 한 계산을 나눠 하는 과학 계산에도 메시지 전달을 씁니다. MPI(Message Passing Interface)는 그 계산에 쓰는 표준 인터페이스입니다.

메시지 전달이 치르는 비용

메시지 전달은 잠금을 없애는 대신 다른 비용 셋을 냅니다.

첫째는 복사입니다. 메시지에 값을 담으면 복사가 생깁니다. 값이 크면 복사하는 시간과 메모리가 늘어납니다. 공유 메모리라면 같은 값을 복사 없이 같이 봅니다.

둘째는 기다림입니다. 값 하나를 읽으려 해도 주인에게 묻고 답이 올 때까지 기다려야 합니다. 변수를 직접 읽는 것보다 거치는 단계가 훨씬 많습니다.

셋째는 교착 상태입니다. 두 작업이 서로에게서 올 메시지를 기다리면 둘 다 영영 멈춥니다. 잠금을 하나도 안 써도 교착 상태는 생깁니다.

쓰는 때와 안 쓰는 때

어느 쪽이 맞는지는 값이 어떻게 움직이느냐에 달렸습니다. 아래 표는 흔한 경우를 모은 것입니다.

상황 맞는 방식 까닭
일을 여러 작업에 나눠 준다 메시지 전달 일감의 주인이 받는 작업으로 옮겨 간다
여러 결과를 한 곳에 모은다 메시지 전달 모으는 작업 하나만 합계를 고친다
단계마다 다른 작업이 맡는다 메시지 전달 앞 단계가 결과를 다음 단계로 넘기면 끝난다
서로 다른 서버가 협력한다 메시지 전달 나눠 쓸 메모리가 없다
여러 작업이 한 값을 자주 읽고 조금씩 고친다 공유 메모리와 잠금 읽을 때마다 묻고 답을 기다리는 비용이 잠금보다 크다

표의 셋째 줄 같은 흐름을 파이프라인이라고 부릅니다. 마지막 줄의 예로는 여러 요청이 함께 읽는 캐시나 요청 수를 세는 카운터가 있습니다.

같은 이름의 다른 쓰임

「메시지 전달」은 동시성 밖에서도 쓰입니다. 객체 지향에서는 메서드를 부르는 일을 「객체에 메시지를 보낸다」고 말합니다. Smalltalk 같은 언어가 이렇게 부릅니다. 이 뜻은 한 실행 흐름 안에서 메서드를 부르는 일이라 동시성과 관계가 없습니다.

메시지 큐를 다룰 때는 「메시지 전달」이 메시지가 받는 쪽에 닿는 일 자체를 가리키기도 합니다. 메시지가 한 번도 안 빠지고 닿는지, 두 번 닿지는 않는지를 따지는 전달 보장이 이 뜻입니다.

관련 항목

메시지 전달과 맞세워지는 공유 메모리 동기화 수단

공유 메모리 · 락 · 뮤텍스 · 세마포어 · 상호 배제 · 임계 구역 · 원자적 연산

메시지 전달로 동시성을 짜는 모형

액터 모델 · Communicating Sequential Processes · 채널 · 메일박스 · 생산자-소비자 패턴

메시지를 주고받는 실행 단위

스레드 · 고루틴 · 프로세스 · 코루틴

메시지 전달을 언어와 라이브러리에 넣은 제품

Go · Erlang · Elixir · Akka · BlockingQueue · MPI

메시지를 쌓아 두고 흘려보내는 장치

버퍼 · 큐 · 비동기 · 백프레셔 · 파이프라인

메시지 전달에서 자주 나는 오류

데드락 · 경쟁 상태 · 고루틴 누수 · 메시지 유실

서버 사이의 메시지 전달을 받치는 기법

분산 시스템 · 램포트 시계 · 벡터 시계 · 메시지 큐 · 발행-구독 · RPC · 전달 보장

프로세스끼리 메시지를 나르는 운영체제 수단

프로세스 간 통신 · 파이프 · 소켓 · 마이크로커널 · 시스템 콜

메시지 전달과 이름이 겹치는 개념

메시지 · 객체 지향 프로그래밍 · Smalltalk · 메서드

다른 이름: message passing · 메시지 패싱