메모리 모델은 프로그램에서 발생하는 메모리 읽기(Read)와 쓰기(Write)가 여러 스레드에서 어떤 순서로 관찰될 수 있는지, 그리고 어떤 동기화 연산이 그 순서를 보장하는지를 정의하는 언어의 명세이다.

이 글에서는 OS가 하드웨어를 어떻게 추상화했는지 부터 순차적 일관성 개념과 Go에서의 Memory Model을 살펴본다.

Process

운영 체제(OS, Operating System)는 하드웨어와 애플리케이션 사이에 위치한 시스템 소프트웨어다.

┌──────────────────────────────────────────────────────────────┐
│                        Application                           │
├──────────────────────────────────────────────────────────────┤
│                  System Call Interface                       │
├──────────────────────────────────────────────────────────────┤
│                    Core OS Functions                         │
├──────────────────────────────────────────────────────────────┤
│                         Hardware                             │
│          (CPU, RAM, Disk, NIC, Timer, GPU, ...)              │
└──────────────────────────────────────────────────────────────┘

OS는 프로그램을 효율적으로 실행하도록 CPU, 메모리, 디스크와 같은 하드웨어를 추상화하고 프로그램이 이를 효율적으로 사용할 수 있도록 관리하는 역할을 담당한다. OS가 시스템 리소스를 안정적이고 효율적으로 사용하기 위해서 실행중인 프로그램을 추상화한 **프로세스(Process)**를 관리한다.

초기의 컴퓨터는 하나의 프로그램이 끝나야 다음 프로그램을 실행할 수 있었다. 이후 시간이 지날 수록 컴퓨터가 빨라지고 사용자가 많아짐으로써 여러 프로그램을 동시에 실행하고 싶어졌다.

하지만 CPU는 동시에 하나의 명령어(한 가지 일)만 처리할 수 있으며 많은 프로그램들은 동시에 컴퓨터의 물리적 메모리(RAM)을 공유해야 한다.

프로그램A와 프로그램B가 완전히 동일한 메모리를 공유하고 있다면, 프로그램A가 같은 메모리 위치를 건드려서 프로그램B가 기대하지 않은 값을 읽어갈 수 있다. 이렇게 같은 메모리 위치를 건드리고, 그 중 최소 하나가 쓰기인 경우를 **데이터 경합(Data Race)**이라고 한다.

이러한 제약조건을 고려했을때 컴퓨터에서 여러 프로그램을 안정적이고 효율적으로 동시에 실행하기 위해서는 아래 2가지를 고려해야 한다.

  • 프로그램별 독립적인 실행 환경
  • CPU의 전환빈도

프로그램별 독립적인 실행환경을 제공하기 위해서 실행중인 프로그램을 추상화한 것을 **프로세스(Process)**라고 한다. 그리고 이 독립성을 보장하는 가장 중요한 매커니즘이 **가상 메모리(Virtual Memory)**이다. 가상 메모리는 RAM을 추상화 한 것으로 볼 수 있다. 운영체제는 각 프로세스에 독립적인 가상 주소 공간(Virtual Address Space)을 제공하여 서로의 메모리에 직접 접근하지 못하도록 격리(Isolation)한다.

CPU는 한 번에 한 가지 일만 할 수 있다. 여러 프로그램이 존재하는 경우 동시에 실행되는 것처럼 보이게 하기 위해서는 CPU의 전환 빈도(conversion frequency)가 빨라야 한다. 프로그램A에서 프로그램B로 전환이 일어날 때 프로그램A는 일시 중단(suspend) 되어야 한다. 일시 중단된 이후 다시 재개(resume)될 때, 이전에 유지했던 상태(context)를 이용해야 한다. 즉, 일시 중단되었을때의 상태(context)를 저장할 자료 구조가 필요하다.

이 자료 구조가 **PCB(Process Control Block)**이며, PCB는 운영체제가 프로세스의 실행 상태(context)와 관리 정보를 저장한다.

OS는 **컨텍스트 스위칭(Context Switching)**을 통해서 실행 중인 프로세스를 다른 프로세스로 교체할 수 있다. 컨텍스트 스위칭은 다음과 같이 실행된다.

  • OS는 현재 CPU에서 실행되는 프로세스의 컨텍스트를 저장한다. 이 컨텍스트에는 PC, Stack Pointer, 범용 레지스터 등이 포함된다.
  • OS는 CPU의 또 다른 프로세스에서 저장된 컨텍스트를 복원하고, CPU에서 이 프로세스의 실행을 시작하도록 한다. 이때 이전에 명령이 중지됐던 곳에서 계속 이어 실행한다.

Thread

프로세스는 독립적인 가상 메모리, PID, CPU 실행 상태 등 독립적인 실행 환경을 갖는다. 하지만 기존 프로세스 방식의 멀티 프로그래밍은 새로운 프로세스를 생성할 때 전체 주소 공간을 복제해야하기 때문에 비용이 많이 들고 속도가 느리다는 단점이 있었다.

이러한 문제를 해결하기 위해서 단일 프로세스내에서 동시에 실행될 수 있으며 프로세스의 메모리와 리소스를 공유하면서 별도의 실행 스택(Stack)을 보유하는 **스레드(Thread)**라는 추상화가 탄생하였다. 현대 애플리케이션에서 스레드란 커널 수준 스레드와, 애플리케이션이 관리하는 사용자 수준 스레드로 나뉜다.

Sequential Consistency

우리는 아래와 같은 코드를 작성하면 순서대로 실행될 것이라고 생각한다.

x = 1
y = 2
z = x + y

하지만 놀랍게도 작성된 코드가 항상 순서대로 실행되진 않는다. 왜냐하면 현대의 CPU와 Compiler는 성능을 극대화하기 위해 명령어의 실행 순서를 변경(Reordering) 할 수 있기 때문이다. 즉, 우리가 작성한 코드의 순서와 CPU가 실행하는 순서는 다를 수 있다는 것이다. 이러한 최적화에 따른 문제는 멀티 스레드 환경에서 발생한다.

예를 들어 CPU가 항상 “쓰기 > 메모리 반영 > 다음 명령” 순서로 처리한다면 처리량이 엄청 떨어질 것이다. 따라서 CPU는 메모리 반영(e.g 캐시 응답)응답을 기다리지 않고 다음 명령어를 빠르게 실행하기 위해서 Store Buffer라는 하드웨어 메모리 공간에 임시 저장을 하게 된다. 그리고 버퍼에 담긴 데이터는 비동기적으로 나중에 캐시나 메모리에 최종 반영된다. 즉, CPU는 지연 시간을 감소시키기 위해서 버퍼와 비동기 매커니즘을 활용한다.

순차적 일관성(Sequential Consistency)은 모든 메모리 연산이 하나의 전역 순서대로 실행되고, 각 스레드의 프로그램 순서도 그대로 유지되는 것처럼 보이는 이상적인 실행 모델이다. 하지만 위에서 설명한 것 처럼 CPU는 순차적 일관성을 항상 보장하지 않는다. 또한 CPU(x86, ARM, Apple Silicon, …)마다 메모리 동작 방식이 다르고 Compiler(Go, JVM, GCC, …)마다 최적화 방식이 다르기 때문에 프로그래머가 모든 하드웨어를 이해하면서 프로그램을 작성하는 것은 불가능하다.

따라서 멀티 스레드 환경에서 안전하게 프로그래밍을 하기 위해서는 CPU와 Compiler에게 “여기에서는 반드시 이 순서를 지켜라"라고 알려주는 계약이 필요하다. 이 계약이 ***메모리 모델(Memory Model)***이다. 메모리 모델은 프로그램에서 발생하는 메모리 읽기(Read)와 쓰기(Write)가 여러 스레드에서 어떤 순서로 관찰될 수 있는지, 그리고 어떤 동기화 연산이 그 순서를 보장하는지를 정의하는 언어의 명세이다.

Go

**Go Memory Model**에서 “Data-Race-Free 프로그램은 Sequentially Consistent하게 동작한다"라고 설명한다. 즉, 프로그램에 Data Race가 없고, Channel, Mutex, Atomic 등 올바른 Synchronization을 사용했다면, CPU가 어떤 최적화를 하든, Compiler가 어떤 재배치를 하든, 프로그램이 순차적 일관성을 달성한다고 생각해도 된다는 의미이다.

메모리 모델에서 가장 중요한 개념이 Happens-Before이며, “A에서 수행한 메모리 쓰기(Write)가 B에서 반드시 관찰(Visible)된다"라는 의미를 가지고 있다.

Go Memory Model에서는 아래와 같이 설명하고 있다.

The happens before relation is defined as the transitive closure of the union of the sequenced before and synchronized before relations.

Happens-Before는 Sequenced-Before + Synchronized-Before + Transitive Closure이다.

Sequenced-Before

같은 고루틴 내부에서는 프로그램 순서가 Happens-Before의 일부가 된다.

x = 1
y = 2
fmt.Println(y)

Synchronized-Before

  • If a package p imports package q, the completion of q’s init functions happens before the start of any of p’s.
  • The start of the function main.main happens after all init functions have finished.
  • The go statement that starts a new goroutine happens before the goroutine’s execution begins.
  • A send on a channel happens before the corresponding receive from that channel completes.
  • The closing of a channel happens before a receive that returns a zero value because the channel is closed.
  • A receive from an unbuffered channel happens before the send on that channel completes.
  • The k’th receive on a channel with capacity C happens before the k+C’th send from that channel completes.
  • For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() happens before call m of l.Lock() returns.
  • A single call of f() from once.Do(f) happens (returns) before any call of once.Do(f) returns.

Transitive Closure

Transitive Closure는 전이성을 의미한다.

// goroutine A
x = 10
ch <- true

// goroutine B
<-ch
y = x
mu.Unlock()

// goroutine C
mu.Lock()
fmt.Println(y)

위 코드는 아래와 같은 체인이 형성된다.

x = 10
    → Send(ch)
        ───HB──▶ Receive(ch)
                     → y = x
                         → Unlock(mu)
                             ───HB──▶ Lock(mu)
                                          → Print(y)
  • → : 같은 고루틴 내부의 Sequenced-Before
  • ────HB────▶ : 고루틴 간 Synchronizes-Before (채널, Mutex 등)

즉, Print(y) 시점에서는 x = 10의 결과가 반드시 관찰 가능(Visible) 하다. 이것이 Happens-Before의 핵심이다.

References