Loading
2022. 2. 9. 00:25 - lazykuna

Modern Language에서 확인하는 소프트웨어 디자인 패턴

Mark Seemann의 Category Theory와 디자인 패턴의 상관관계에 대한 포스트에서, 소프트웨어는 미리 만들어진 블록들을 이용해 만들어지는 것에 비유할 수 있다고 이야기하였다. 여기서 미리 만들어진 블록들이라는 것은 추상화 가능한 개념이다. 즉, 실제 사용되는 product들이 사용하고 추구하는 디자인에서 그러한 추상화 방식들을 (여기서는 편의를 위해 디자인 패턴으로 취급하기로 함) 배울 수 있을 것이다.

최근 프로그래밍 언어에서는 이러한 개념들이 많이들 적용되어 있다고 한다. 이를테면 Golang, Rust... 실제로 보면 참 신기한 요소들이 많았다. 그런데 이러한 언어들을 써 보다 보니, 계속해서 변하는 프레임워크와 대조하여 안정적일 거라고 생각했던 소프트웨어 디자인 패턴은 내 생각과는 달리 계속 발전하고 있었다는 것을 알 수 있었다. 내가 모르는 내용들도 많았고, 이전에 고민하던 내용에 대한 답도 프로그래밍 언어가 추구하는 모델에 자연스럽게 녹아 있었다. 더 나아가서는 프로그래밍 언어 뿐만이 아니라, 근래 소프트웨어 아키텍처 등에도 이러한 변화들이 곳곳에 녹여들어가 있었다.

그래서 요즘 최신의 언어와 소프트웨어(MSA, 오픈소스 등)의 아키텍처들을 유심히 살펴보고 있다. 그리고 살펴본 내용들을 아직 미숙하지만 정리해 보려고 한다. 일단 최신 언어들에 대해서...
사실 이 포스트에서 다루는 언어들을 최신이라고 하기엔 좀 되었죠. 슬슬 주류급으로 올라오고 있습니다. 그리고 각각의 언어와 아키텍처들의 심도깊은 철학을 포스트 하나로 다루는 데다가, 글쓴이의 미숙함으로 인해 상당히 깊이가 부족한 것은 감안해야 합니다 ㅠㅠ

Node.js (libuv)

먼저 원조격인 Node.js(javascript)에 대해서 알아보았다.

callback 패턴

Node.js라고는 썼지만 사실상 ES6 이후 자바스크립트 엔진을 기반으로 하므로 문법상 기능은 똑같다. 아무튼, 자바스크립트는 원초적인 형태의 비동기 패턴을 사용하는데 바로 callback 패턴이다.

function logName() {
  var user = fetchUser('domain.com/users/1', function(user) {
    if (user.id === 1) {
      console.log(user.name);
    }
  });
}

위 함수에 대해서 설명을 하면, 특정 url에 대해서 값을 fetch해 오도록 하는 fetchUser 비동기 함수를 수행하고, 비동기 작업이 완료되면 "완료" 이벤트가 호출되고, 이 때 넘겨준 anonymous function이 callback으로서 수행될 것이다.

사실 이러한 패턴은 "리액터 패턴"으로 많이 알려져 있다. anon function이 Handler이고, Reactor이 handler을 받아오는 fetchUser 함수이고, Dispatch 함수가 내부에서 수행되는 fetch 작업이라고 볼 수 있다. 이러한 일련의 과정들이 Non-blocking으로 일어나는 것을 리액터 패턴이라고 부르고, 여기에서 사용하는 callback 형태는 그것들 중 하나이다.

"이벤트 기반 비동기 패턴"과도 비슷하다. 함수 인자로 넘겨주는거나 클래스 오버라이딩을 하거나, 클래스 멤버로 던져주는거나 결국 어쨌든 다 똑같이 callback할 함수 만들어서 던져 주는 일이다.

어쨌든, 이는 Node.js의 기반이 되는 c 라이브러리 libuv에서도 마찬가지다. callback 함수를 만들고, 이를 비동기 함수에 넘겨준다.

...
    /* initialize the new client */
    uv_tcp_init(loop, client);

    /* now let bind the client to the server to be used for incomings */
    if (uv_accept(server, (uv_stream_t *) client) == 0) {
        /* start reading from stream */
        int r = uv_read_start((uv_stream_t *) client, (uv_alloc_cb)alloc_buffer, read_cb);

        if (r) {
            printf("error 3\n");
        }
    }
...


void read_cb(uv_stream_t * stream, ssize_t nread, uv_buf_t buf) {
    /* dynamically allocate memory for a new write task */
    uv_write_t * req = (uv_write_t *) malloc(sizeof(uv_write_t));
    ..
}

문제는 callback 지옥에 빠질 수 있다는 것이다. callback한 함수가 또 다른 비동기 작업을 호출해야 하고, 이게 이중 삼중으로 중첩되어 있으면 정말 끔찍하다.

이런 식으로...

마법과 같은 도구: async/await

그래서 자바스크립트에서는 await이라는 일종의 마법 도구를 제공한다. 비동기를 동기처럼 쓸 수 있도록 하는 강력한 도구이다! 자세한 내용은 async/await 동작원리를 찾으면 나오지만, 간단히 설명하면 await를 사용하면 비동기(async) 함수의 수행이 끝나기 전까지 기다리는 형태로 만든 것이다.

덕분에 위의 예제는 아래와 같은 코드로 깔끔하게 바꿀 수 있다.

function logName() {
  var user = await fetchUser('domain.com/users/1');
  console.log(user.name);
}

그런데 이 await라는 키워드가 정확히 무엇을 하는 것일까? async 객체가 리턴하는 값은 Promise 객체이다. 실제로 async 객체에 대해서 Promise 객체의 메서드(then, catch)들을 적용할 수 있다. 즉, 위의 코드는 Promise 객체에 대해서 await를 걸어준다고 볼 수 있다.

비동기 상황에서 결과값을 얻어오는 방법: Promise 객체 (Active Object)

Promise객체는 "약속된 결과값" 에 해당한다. 비동기 작업이니까 당장 결과값이 준비되어 있지는 않겠지만, 작업이 완료되면 결과값을 담아 전달해주며, 필요하다면 결과값이 나올때까지 대기하는 역할도 수행한다.

예전에는 비동기 함수를 수행할 때 결과값을 어떻게 처리할지에 대한 로직을 작성하고 통째로 넘겨주었다면, 이제는 비동기 상황 자체를 객체화시킴으로서 코드의 복잡성을 훨씬 줄일 수 있게 된 것 뿐만 아니라, promise.wait() 와 같은 방식으로 프로그램의 흐름또한 쉽게 통제 가능해졌다.

필요하다면 일부 상황에 대해 callback을 넘겨줄 수가 있는데, 이를테면 비동기 상황에서 예외 상황 발생시 처리할 핸들러를 넘겨주거나, 비동기가 끝난 이후 수행할 callback을 담아둘 수도 있다. (then, catch 이용)

참고로, 여러 Promise 객체를 동시에 기다리도록 하는 유틸리티로 Promise.all이 있다. 밑에서 다룰 golang의 waitgroup와 유사한 용도로 쓴다.

C++

C++은 오래된 언어지만 계속 진화해 왔다. 비동기 디자인 측면에서도 그렇다. javascript와 유사한 점이 많은데, 과거에는 callback 위주로 짤 수밖에 없었고 + lambda 형태의 함수도 사용할 수 없어 코드가 끔찍하게 복잡했지만, C++11 이후 추가된 future 및 async 객체 등으로 훨씬 동시성 패턴 디자인하기가 쉬워졌다.

modoocode에서 빌려온 코드를 보면,

#include <future>
#include <iostream>
#include <thread>
#include <vector>

// std::accumulate 와 동일
int sum(const std::vector<int>& v, int start, int end) {
  int total = 0;
  for (int i = start; i < end; ++i) {
    total += v[i];
  }
  return total;
}

int parallel_sum(const std::vector<int>& v) {
  // lower_half_future 는 1 ~ 500 까지 비동기적으로 더함
  std::future<int> lower_half_future =
    std::async(std::launch::async, sum, cref(v), 0, v.size() / 2);

  // upper_half 는 501 부터 1000 까지 더함
  int upper_half = sum(v, v.size() / 2, v.size());

  return lower_half_future.get() + upper_half;
}

int main() {
  std::vector<int> v;
  v.reserve(1000);

  for (int i = 0; i < 1000; ++i) {
    v.push_back(i + 1);
  }

  std::cout << "1 부터 1000 까지의 합 : " << parallel_sum(v) << std::endl;
}

javascript에 빗대면, std::promise 객체가 async 메서드와 같은 역할을 하고, promise::wait()가 await 메서드와 같은 역할을 하는 것이다.

이전 같았으면 명시적으로 thread를 생성하고, thread.join()으로 수행이 끝나기를 기다리고, callback 함수를 만들고 넘겨서 결과값을 처리하도록 했어야 했다. 단지 promiseasync를 사용함으로서 비동기 코드가 이 정도로 깔끔해질 수 있다는 것은 정말 장족의 발전이다.

하지만 std::async는 내부적으로 OS 쓰레드를 생성하는 과정을 수행하고 있어 효율성이 떨어지고, thread pool 디자인을 적용할 수도 없다. 이는 Worker를 객체로 만들어서 전달하는 방식을 쓰거나, std::packaged_task를 사용하여 해결할 수 있다. 둘 다 결국은 task를 객체화 시켜서 전달하는 게 핵심이다.

#include <future>
#include <iostream>
#include <thread>

int some_task(int x) { return 10 + x; }

int main() {
  std::packaged_task<int(int)> task(some_task);

  std::future<int> start = task.get_future();

  std::thread t(std::move(task), 5);

  std::cout << "결과값 : " << start.get() << std::endl;
  t.join();
}

타 언어들 대비 새로운 기능은 없지만, 역사가 오래된 C++에서도 이 정도의 디자인 패턴을 제공하고 있으며, 이를 적용함으로서 모던한 디자인의 코드를 짤 수 있다는 게 핵심일 것 같다.

Golang

goroutine

먼저 goroutine 자체에 대해서 간단하게 짚고 넘어갈 필요가 있다. Golang 런타임에서 관리하는 경량 스레드(일명 Green thread)이고, 언어 특성상 스레드 생성에 대해 고민할 필요가 없이 상당히 간편하게 짤 수 있도록 만들어져 있다.

경량 스레드와 OS 스레드의 차이점에 대해서 다룬 곳이 많기 때문에 간략히 설명하면 훨씬 가볍다. OS 단위에서 스레드를 생성하는 비용은 생각보다 크다. 물론 프로그램의 기본적인 무게 자체가 무거워지는 단점이 있어 일단락이 있기는 하다.

그리고 golang 고유의 기능으로 스레드 스케줄링을 자동으로 해 준다. 예를 들면, 아래 코드처럼 멀티스레드라는 것을 명시하지 않고 일반 함수처럼 짜도,

func main() {
    go test(n int) {
        for i := 0; i < n; i++ {
            fmt.Println(i)
        }
    }(10)
}

goroutine이 알아서 멀티스레드로 수행하는데, 세부적으로는 저 "n"으로 주어진 값을 알아서 스레드 개수에 따라 분배해서 13, 46, 7~10 이런 식으로 수행한다. 굉장하다!

goroutine 자체의 스케줄링 세부사항에 대해서도 공부할만한 내용이 많은데, 그 부분은 아무래도 이미 정리된 곳이 많으니 그곳을 참조하면 될 듯...

22-02-18 추가) C++17 에서도 std::execution 을 통해서 병렬 실행에 대한 정책이 생겼다! 이 점에 대해 성능관련하여 꽤 흥미로운 이야기가 있는데, 이후에 기회가 되면 별도 포스팅으로...

비동기 상황에서 결과값을 얻어오는 방법: channel

앞선 Node.js에서는 callback에서 결과값을 받아 처리하는 가장 원초적인 방식을 보여주었고, 이후 await을 이용해서 비동기 상황의 결과값을 부모 함수에서 받아 처리하도록 하였다.

golang에서 goroutine은 아예 리턴값이 없다. 흔히들 많이 쓰는 Future 패턴을 아예 배제해 버렸다. 대신 channel을 이용하여 결과값을 받아오도록 설계하였다. 그럴수밖에 없기도 한 점이, goroutine은 기본적으로 다수의 스레드를 전제로 수행되기 때문에 기존의 future 객체로 값을 전달받기에는 내부 구현을 많이 노출시켜야 하는 문제점이 있을 것이다.

channel 자체는 대충 pipe랑 비슷하다. 내부 구현은 queue에 값이 있어야 pop이 되는 Guarded suspension 구조일 것이다.

Future 객체도 없다고 해서 await을 할 수 없는 건 아니다. Waiting group이 있다.

Dangling에 대한 위험 해결

여기는 딱히 적용된 디자인 패턴 사항은 없지만, TMI로 적어 두었다 ㅡ,ㅡ;

이건 스마트 포인터를 사용하는 고수준 언어들이 으레 그렇지만 (javascript도 그러하다), golang도 마찬가지로 객체 삭제를 명시적으로 하지 않아도 GC가 처리해주기 때문에 pointer dangling에 대한 걱정이 없다.

보통은 Mark-and-sweep 디자인을 사용할 것이다. reference counting 디자인은 쓰지 않는데, 으레 알려져 있듯이 순환 참조에서는 memory leak이 일어나기 때문이다. #

다만 JVM의 GC와는 다르게, 스택과 heap 형태의 메모리 구조를 둘다 가지고 있고, 이를 최대한 효율적으로 쓸 수 있게 설계가 되어있다. 제일 놀라운 점은 heap에 compaction을 하지 않아 속도도 빠른데 메모리 효율도 나쁘지 않게 고려한 부분이다! 자세한 내용은 golang gc 관련하여 찾아보면 많이 있을 것이다.

Rust

함수형 언어

이건 소프트웨어 아키텍처라기보다는 프로그래밍 언어의 디자인에 대한 이야기지만, 일단 중요한 내용이니 짤막하게 짚고 넘어가본다.

Rust는 함수형 언어의 성격을 많이 차용한 게 보인다.

  • Rust의 변수는 기본적으로 immutable 하다.
  • Rust의 변수는 Shadowing이 된다.
  • Rust의 상수는 반드시 type을 선언해 주어야 한다.

등등의 특징이 있는데, 함수형 언어는 여러 장점이 있다고 알려져 있다. 잘 알려진 내용으로

  • 높은 수준의 추상화를 제공한다
  • 함수 단위의 코드 재사용이 수월하다
  • 불변성을 지향하기 때문에 프로그램의 동작을 예측하기 쉬워진다

이에 관해서는 여러 책들이나 아티클들이 있기 때문에 깊이 있게 다루지는 않지만, 사용하고 있는 선언형 프로그래밍들의 예시들을 직접 보면 컴퓨터보다 사람 친화적인 언어라는 것을 알 수 있다. 복잡한 코드로 골치를 앓았던 사람이라면 어렵지 않게 납득할 수 있을 것 같다. 이에 대해 아래 예시를 가장 좋아한다.

// https://evan-moon.github.io/2019/12/15/about-functional-thinking/

function convert (s) {
  return s.charAt(0).toUpperCase() + s.substring(1);
}

// Imperative
const newArr = [];
for (let i = 0; i < arr.length; i++) {
  if (arr[i].length !== 0) {
    newArr.push(convert(arr[i]));
  }
}

// Declarative
let arr = arr
  .filter(v => v.length !== 0)
  .map(v => convert(v));

두 로직을

발전된 type

Rust는 복잡한 type 관리 시스템으로 유명한데, 실제로 type에 상당히 많은 특징이 있다.

  • 기본적인(Generic) 타입 매치 검사
  • trait 검사
  • ownership
  • lifetime

또한, 기본적으로 제공되는 타입(Generic type) 자체가

  • primitive type들은 물론이고,
  • 기본 열거형들(Option, Result)과 이에 대한 match 키워드
  • 문자열에 대한 slice 타입

이러한 type 디자인을 통해서 Rust는 컴파일 단계에서 문제를 미연에 방지하는 것을 원칙으로 하고 있고, 동시에 순수 성능 측면에서도 신경을 썼다.

try ~ catch가 없다?

Rust는 exception도, exception 핸들러도 없습니다. 대신, Rust는 고유의 type checking system을 통해서 오류를 처리한다. 아까 이야기한 Result<T, E> enum을 통해서 이를 해결한다. 아래의 코드로 예를 들면,

// from: https://dgkim5360.tistory.com/entry/what-i-learned-from-the-rust-book-chapter-9-error-handling
use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Tried to create file but there was a problem: {:?}", e),
            },
            other_error => panic!("There was a problem opening the file: {:?}", other_error),
        },
    };
}

이러한 구조를 통해서 Rust에서는 아래의 장점을 취한다고 생각한다.

  • 오류 처리 강제
    • Rust의 강력한 타입 체크 덕분에, Result<T, E> 꼴의 함수는 결과값만 절대로 받아올 수 없다. 오류 처리 로직이 대동되어야 한다.
      사실, unwrap() 써버리면 이마저도 의미가 없지만 ... rust를 쓸 정도의 사람이면 그러진 않을 거라고 생각한다
  • 성능 향상: try ~ catch 구문에서의 stack rewinding cost를 제거
    • C++ 같은 경우 exception 처리를 위해 소모하는 CPU time이 상당히 크다.

그래도 assert와 같은 기능이 아예 없는 것은 아닌데, panic! 기본 내장 함수가 있다. 물론 이건 스택을 풀고(unwind the stack) 프로그램을 끝내는 전통적인 방식의 die 방식이니까, 예외 처리랑은 거리가 있고, 그냥 참고용으로...

빼먹고 언급을 안 했는데, 이 점은 golang도 똑같다.

Dangling pointer - ownership, lifetime

Rust는 스마트 포인터를 사용하지는 않는다. 대신 dangling pointer에 대한 위험성을 컴파일 단계에서 철저히 배제한다. 이는 ownership의 검사를 통해서 진행하는데, 이미 C++ 같은 데에도 비슷한 개념이 몇 가지 있긴 하다. unique_ptr이나, 참조하는 값을 바꾸지 못하게 하는 const qualifier이나...

unique_ptr에서는 소유권을 옮겨주기 위해서 std::move를 이용했는데, rust는 그런 게 필요 없다. 기본적으로 변수를 넘겨주는 행위는 move이다. unique_ptr 같은 걸 힘들여 안 써도 기본으로 지원해준다는 것 자체가 몹시 마음에 든다.

그리고 특이한 게 빌림(Borrowing) 이라는 개념이다. 참조자에 대한 규칙인데, 첫번째는 mut 참조자를 2개 이상 만들지 못하게 하여 data race를 컴파일 타임에 방지하는 것이고, 두 번째는 불변 참조(빌림)을 했다면 해당 객체에 대해서 mutable 참조와 같이 잠재적으로 값을 수정할수 있는 행위를 모두 금지하는 것이다. rust-lang-book에 이에 대한 철학을 잘 드러내 주는 코드가 있다.

let mut s = String::from("hello");

let r1 = &s; // 문제 없음
let r2 = &s; // 문제 없음
let r3 = &mut s; // 큰 문제

어휴! 우리는 불변 참조자를 가지고 있을 동안에도 역시 가변 참조자를 만들 수 없습니다. 불변 참조자의 사용자는 사용중인 동안에 값이 값자기 바뀌리라 예상하지 않습니다! 하지만 여러 개의 불변 참조자는 만들 수 있는데, 데이터를 그냥 읽기만하는 것은 다른 것들이 그 데이터를 읽는데에 어떠한 영향도 주지 못하기 때문입니다.

나(source)를 참조하고 있는 다른 객체가 있으면 source는 변하면 안 된다는 것은 당연한 이야기인데, 이걸 컴파일러단에서 캐치해준다는 것은 굉장히 놀랍다고 생각한다! 잘못된 코드를 짤 확률을 굉장히 줄여주는 획기적인 도구라고 생각한다.

또 이러한 철저한 ownership 관리를 통해서, 별도의 동적 메모리를 수동으로 관리할 필요가 없게 된다. ownership이 없어지면 알아서 할당을 해제하게 되어, 고수준 언어와 유사한 형태로 변수를 사용할 수 있으면서 동시에 성능은 저수준 언어의 장점을 취할 수 있다. 정말 멋지다!

그리고 rust에서는 변수의 lifetime도 확인해주는데, 위의 빌림(borrowing) 개념과 포함해서 훨씬 강력한 checking을 수행해준다. 아래의 예를 들면,

{
    let r;         // -------+-- 'a
                   //        |
    {              //        |
        let x = 5; // -+-----+-- 'b
        r = &x;    //  |     |
    }              // -+     |
                   //        |
    println!("r: {}", r); // |
                   //        |
                   // -------+
}

통상적인 프로그래밍 언어에선 참조하는 r 변수가 최상위 블록에 위치하고 있으므로 아무 문제없이 통과한다. 하지만 Rust에서는 빌림 검사를 하기 때문에, r에서 빌림받은(?) 변수 x에 대한 scope 검사를 컴파일단에서 수행할 수 있게 된다. 다른 언어였으면 런타임 가서나 실험적으로 확인할 수 있었을 텐데...

또 재미있는 점은, 이 lifetime을 type의 요소로서 지정할 수 있다는 것이다.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    // error: string2 lifetime is shorter than result!
    println!("The longest string is {}", result);
}

두 문자열을 참조자로 받아 더 긴 문자열의 참조를 리턴하는 함수인데, 인자로 받는 변수와 결과 변수의 lifetime이 동일해야 함을 함수에 명시해 준 모습이다.

위 구조에서 dangling을 내려는 어떠한 시도도 컴파일러가 다 막아낸다. 정말이지 굉장하다...

물론 lifetime 명시는 생략 가능하다. 이에 관한 내용은 여기 참조

rust의 trait

rust언어의 특이한 점 중 하나는 Trait이라고 생각한다. 공유 동작에 대한 정의를 의미하는데, java의 interface와 비슷하기도 하지만 template적인 특성이 강하다는 것이 특징이다. 구체적으로는 type이 미정의여도 trait impl을 할 수 있고, type에 대해서 정의를 하는 것이 아니라 trait 그 자체에 대해서 정의를 한다던가 등등...

trait 구현에 대해서는 다루고 있는 다른 글들이 많이 있으므로 생략하고, 가장 큰 특징은 바로 trait이 타입으로서 작용한다는 것이라고 생각한다. 이를테면...

pub fn notify<T: Summarizable>(item: T) {
    println!("Breaking news! {}", item.summary());
}

아래에서처럼 특정 type을 받되, 그 type이 Summerizable이라는 Trait를 만족해야만 하는 제약조건을 거는 것이다. Trait의 본래 뜻인 "특성"과 이렇게나 잘 부합하는 기능이 또 있을까...

그리고 Rust에서는 일부 trait을 기본적으로 제공해주고 있다.

  • Send: 특정 type이 스레드 사이를 건너갈 수 있도록 하는 Trait
  • Sync: 특정 type이 스레드 사이에서 참조(공유)될 수 있도록 하는 trait
  • Copy: assignment(a = b)시 move가 아니라 copy를 수행하도록 하는 trait
  • Clone: 완전하게 객체를 복제해주도록 하는 trait. .clone()으로 메서드가 제공된다.
  • Deref/DerefMut: 와
  • Debug: fmt 메시지를 출력해주는 trait (toString 같은 느낌으로...)
  • 등등...

이들 중 SendSync는 순수 마커 트레잇으로, 아무것도 구현할 메서드가 없다. 진짜 타입처럼 쓰고 있는 것이다.

Rust가 스레드 안전성을 보장하는 방법

Rust의 엄격한 type checking은 스레드 안전성에서도 발군의 효과를 보여준다.

보통 스레드의 안전성이 침해되어 멀티스레드에서 문제가 발생하는 경우부터 살펴보면, Rust-lang-book 공식에서는 이렇게 이야기 하고 있다.

  • Race condition: 여러 스레드들이 일관성 없는 순서로 데이터 혹은 리소스에 접근
  • Deadlock: 두 스레드가 서로 상대방 스레드의 리소스의 사용을 끝내길 기다리는 상황
  • 이외 특정 상황에서만 재현되는 타이밍 이슈들

결국 이러한 문제들은 스레드를 시작할 때 주어진 값을 동시에 접근하려고 하기 때문에 발생하는 것이다. 따라서, Rust에서는 기본적으로 스레드에 데이터를 넘겨줄 때 move 클로저를 사용하는 것을 원칙으로 한다.

rust-lang-book의 예시를 참고하면

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });

    drop(v); // oh no!

    handle.join().unwrap();
}

이 예시는 컴파일 되지 않는데, 이에 대해 이렇게 설명하고 있다.

러스트는 생성된 스레드가 얼마나 오랫동안 실행될지 말해줄 수 없으므로, v에 대한 참조자가 항상 유효할 것인지를 알지 못합니다.

스레드에 ownership을 요구함으로서 lifetime 문제를 해결하겠다는 뜻이다.

let handle = thread::spawn(move || {
    println!("Here's a vector: {:?}", v);
});

Rust에서는 이러한 방식으로 ownership을 move하여 문제를 해결하기를 의도하고 있다.

또한, 데이터 공유로 인해서 여러 문제가 발생하는 것을 막고자, Rust 또한 스레드 안전성 보장을 위해 Channel 통신을 지향하고 있다. 다시 한번 rust-lang-book의 구절을 빌려오면...

안전한 동시성을 보장하는 인기 상승중인 접근법 하나는 메세지 패싱 (message passing) 인데, 이는 스레드들 혹은 액터들이 데이터를 담고 있는 메세지를 서로 주고받는 것입니다. Go 언어 문서 의 슬로건에 있는 아이디어는 다음과 같습니다: "메모리를 공유하는 것으로 통신하지 마세요; 대신, 통신해서 메모리를 공유하세요"

이외에도 Trait(Send, Sync)를 통해서, 특정 타입이 스레드 사이를 넘어갈 수 있는지 없는지에 대한 조건을 검사하도록 되어 있다.

이를 조금 더 어려운 예제로 보자. 위 예제에서 reference를 넘겨주는 경우는 lifetime 문제로 compile failure이 생긴다고 했는데, static 이라면 문제가 해결될 것이다. 그러면 mut를 넘겨주는 경우라면 어떻게 될까? Sync trait은 여러 스레드에서 동시 접근이 가능하다는 뜻인데, mut는 data race 방지를 위해 !Sync이다. 자연스럽게 스레드에 넘겨주지 못함으로서, 스레드 안전성을 보장한다.

정리하면...

  • 최신 언어들은 동시성에 상당히 신경을 많이 쓰고 있고,
  • 동시성 상에서의 가독성을 확보하기 위해서 move semantic 및 future 객체를 이용하는 디자인을 많이 보여주고 있으며,
  • 더 나아가서는 channel 을 이용하여 스레드 안전성을 확보하고 있다.

읽어볼만한 책들

  • 고전 서적이라면 GoF, POSA(Pattern-Oriented Software Architecture) 동시성파트 2권을 비롯한 책들 여러권이 있을 것이지만
    • 이러한 이론적인 책을 굳이 다 읽는 시간보다는 역시 modern 아키텍처와 프로그래밍 언어들을 직접 체득하는 것이 시간적으로나 실용적으로나 더 유용할 수 있다...
  • 실무적/실용적인 책들이라면 Clean Code, Refactoring 2, Working with Legacy code 같은 것들이 있을 것이다.
    • 위에서 말한 _언급하지 못했던 패턴_들에 대해서 많은 정보들이 있다
    • 이건 나중에 다른 포스트에서 다루는 걸로...

출처 및 참고했던 내용들