ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • When Rust hurts(번역)
    프로그래밍/rust 2023. 4. 20. 11:10
    728x90

    원문

    https://mmapped.blog/posts/15-when-rust-hurts.html#objects-values-references

    Functional programming deals with values; imperative programming deals with objects.

    Alexander Stepanov, “Elements of Programming”, p. 5

    함수형 프로그래밍은 값을 다루고 명령형 프로그래밍은 객체를 다룹니다.

    알렉산더 스테파노프, "프로그래밍의 요소", p. 5

    소개

    Rust는 언어 디자인 분야에서 독보적인 위치를 차지하고 있습니다. 간결하고 이식성이 뛰어나며 때로는 예쁜 코드로 효율적이고 메모리에 안전한 프로그램을 만들 수 있습니다.

    하지만 장미와 햇살만 있는 것은 아닙니다. 메모리 관리 세부 사항이 종종 방해가 되어 Haskell이나 OCaml과 같은 "상위 수준" 프로그래밍 언어보다 코드가 더 추악하거나 반복적인 경우가 많습니다. 거의 대부분의 경우 이러한 문제는 컴파일러의 결함이 아니라 Rust 팀의 설계 선택에 따른 직접적인 결과입니다.

    이 글에서는 함수형 프로그래밍 사고방식으로 접근하면 Rust가 얼마나 실망스러울 수 있는지, 그리고 왜 Rust가 실망스러울 수밖에 없는지에 대해 자세히 설명합니다.

    Objects, values, and references

    값과 객체는 상호 보완적인 역할을 합니다. 값은 변하지 않으며 컴퓨터의 특정 구현과 무관합니다. 객체는 변경 가능하며 컴퓨터 고유의 구현이 있습니다.

    알렉산더 스테파노프, "프로그래밍의 요소", p. 5

    객체, 값, 참조의 차이점을 이해하면 Rust에 대해 더 자세히 알아보기 전에 도움이 됩니다.

    이 글의 맥락에서 값은 숫자나 문자열과 같이 고유한 정체성을 가진 엔티티입니다. 객체는 컴퓨터 메모리에 있는 값의 표현입니다. 참조는 객체 또는 그 부분에 액세스하는 데 사용할 수 있는 객체의 주소입니다.

    C++ 및 Rust와 같은 시스템 프로그래밍 언어는 프로그래머가 객체와 참조를 구분하여 처리해야 합니다. 이러한 구분을 통해 매우 빠른 코드를 작성할 수 있지만, 그 대가는 끝없는 버그의 원천이라는 것입니다. 프로그램의 다른 부분에서 해당 객체를 참조하는 경우 객체의 내용을 수정하는 것은 거의 항상 버그가 됩니다. 이 문제를 해결하는 방법에는 여러 가지가 있습니다:

    -> 문제를 무시하고 프로그래머를 신뢰합니다. C++와 같은 대부분의 전통적인 시스템 프로그래밍 언어는 이 방법을 사용했습니다.
    -> 모든 객체를 변경 불가능하게 만듭니다. 이 옵션은 하스켈과 클로저의 순수 함수형 프로그래밍 기법의 기본입니다.
    -> 참조된 객체의 수정을 방지하는 타입 시스템을 채택합니다. ATS 및 Rust와 같은 언어가 이 여정을 시작했습니다.
    -> 참조를 완전히 금지합니다. Val 언어는 이러한 프로그래밍 스타일을 탐구합니다.

    객체와 참조의 구분은 우발적인 복잡성과 선택의 폭증을 야기하는 원인이기도 합니다. 불변 객체와 자동 메모리 관리 기능을 갖춘 언어를 사용하면 이러한 구분을 무시하고 모든 것을 값으로 취급할 수 있습니다(적어도 순수 코드에서는). 통합 스토리지 모델은 프로그래머의 정신적 자원을 확보하고 더 표현력 있고 우아한 코드를 작성할 수 있게 해줍니다. 그러나 편의성을 얻는 대신 효율성을 잃게 됩니다. 순수 함수형 프로그램은 종종 더 많은 메모리를 필요로 하고 응답이 느려질 수 있으며 최적화하기가 더 어렵습니다(마일리지가 달라질 수 있습니다).

    추상화가 아플 때

    수동 메모리 관리와 소유권 인식 유형 시스템은 코드를 더 작은 조각으로 세분화하는 데 방해가 됩니다.

    공통 표현식 제거

    공통 표현식을 변수로 추출하면 예상치 못한 문제가 발생할 수 있습니다. 다음 코드 스니펫부터 시작하겠습니다.

    let x = f("a very long string".to_string()); 
    let y = g("a very long string".to_string()); 
    // …

    보세요, "a very long string".to_string() 이 두 번 등장합니다! 우리의 첫 번째 본능은 표현식에 이름을 지정하고 두 번 사용하는 것입니다:

    let s = "a very long string".to_string(); 
    let x = f(s);
    let y = g(s);

    그러나 첫 번째 순진한 버전은 문자열 유형이 복사 특성을 구현하지 않기 때문에 컴파일되지 않습니다. 대신 다음 표현식을 작성해야 합니다:

    let s = "a very long string".to_string(); f(s.clone()); 
    g(s);

    메모리 복사가 명시적이 되었기 때문에 추가 메모리 할당에 신경을 쓴다면 추가 장황함을 긍정적인 시각으로 볼 수 있습니다. 하지만 실제로는 특히 두 달 후에 h(s)를 추가할 때 상당히 성가실 수 있습니다.

    let s = "a very long string".to_string(); f(s.clone()); 
    g(s); 
    
    // fifty lines of code...
    
    h(s); // ← won’t compile, you need scroll up and update g(s).

    단형성 제한(Monomorphism restriction)

    Rust에서 let x = y;가 항상 x가 y와 같다는 의미는 아닙니다. 이 자연 속성이 깨지는 한 가지 예는 y가 과부하된 함수일 때입니다.

    예를 들어 오버로드된 함수의 짧은 이름을 정의해 보겠습니다.

    // Do we have to type "MyType::from" every time?
    // How about introducing an alias?
    let x = MyType::from(b"bytes");
    let y = MyType::from("string");
    
    // Nope, Rust won't let us.
    let f = MyType::from;
    let x = f(b"bytes");
    let y = f("string");
    //      - ^^^^^^^^ expected slice `[u8]`, found `str`
    //      |
    //      arguments to this function are incorrect

    컴파일러가 f를 다형성 함수가 아닌 MyType::from의 특정 인스턴스에 바인딩하기 때문에 코드 조각이 컴파일되지 않습니다. f를 명시적으로 다형성으로 만들어야 합니다.

    // Compiles fine, but is longer than the original. fn f<T: Into<MyType>>(t: T) -> MyType { t.into() }
    
    let x = f(b"bytes"); 
    let y = f("string");

    하스켈 프로그래머라면 이 문제가 익숙할 것입니다. 이 문제는 무시무시한 단형성 제한(monomorphism restriction)과 의심스러울 정도로 비슷해 보이기 때문입니다! 안타깝게도 rustc에는 NoMonomorphismRestriction 프라그마가 없습니다.

    기능 추상화(Functional abstraction)

    컴파일러가 함수 경계를 가로지르는 앨리어싱에 대해 추론할 수 없기 때문에 코드를 함수로 인수 분해하는 것이 생각보다 어려울 수 있습니다. 다음 코드가 있다고 가정해 보겠습니다.

    impl State {
      fn tick(&mut self) {
        self.state = match self.state {
          Ping(s) => { self.x += 1; Pong(s) }
          Pong(s) => { self.x += 1; Ping(s) }
        }
      }
    }

    self.x += 1 문이 여러 번 나타납니다. 메서드로 추출해 보세요...

    impl State { 
        fn tick(&mut self) { 
            self.state = match self.state { 
                Ping(s) => { self.inc(); Pong(s) } // ← compile error 
                Pong(s) => { self.inc(); Ping(s) } // ← compile error 
                } 
            } 
        fn inc(&mut self) { 
            self.x += 1; 
        } 
    }

    주변 컨텍스트가 여전히 self.state에 대한 변경 가능한 참조를 보유하고 있는 동안 메서드가 self를 독점적으로 다시 빌리려고 시도하기 때문에 Rust가 짖게 됩니다.

    Rust 2021 버전은 클로저와 관련된 유사한 문제를 해결하기 위해 분리형 캡처를 구현했습니다. Rust 2021 이전에는 x.f.m(|| x.y)처럼 보이는 코드가 컴파일되지 않을 수 있지만 수동으로 m과 클로저를 인라이닝하면 오류가 해결됩니다. 예를 들어 지도와 지도 항목의 기본값을 소유하는 구조체가 있다고 가정해 보겠습니다.

    struct S { map: HashMap<i64, String>, def: String } 
    
    impl S { 
        fn ensure_has_entry(&mut self, key: i64) { 
        // Doesn't compile with Rust 2018: 
        self.map.entry(key).or_insert_with(|| self.def.clone()); 
    // | ------ -------------- ^^ ---- second borrow occurs... 
    // | | | | 
    // | | | immutable borrow occurs here 
    // | | mutable borrow later used by call 
    // | mutable borrow occurs here 
        } 
    }

    그러나 or_insert_with의 정의와 람다 함수를 인라인으로 연결하면 컴파일러에서 차용 규칙이 유지되는 것을 확인할 수 있습니다.

    struct S { map: HashMap<i64, String>, def: String }
    
    impl S {
      fn ensure_has_entry(&mut self, key: i64) {
        use std::collections::hash_map::Entry::*;
        // This version is more verbose, but it works with Rust 2018.
        match self.map.entry(key) {
          Occupied(mut e) => e.get_mut(),
          Vacant(mut e) => e.insert(self.def.clone()),
        };
      }
    }

    "명명된 함수가 할 수 없는 Rust 클로저의 트릭은 무엇인가요?"라고 누군가 묻는다면, 사용하는 필드만 캡처할 수 있다는 답을 알 수 있습니다.

    새로운 유형 추상화(Newtype abstraction)

    새로운 유형 관용구 (C++에서는 이 관용구를 강력한 typedef라고 부릅니다.) Rust에서는 프로그래머가 기존 유형에 새로운 정체성을 부여할 수 있습니다. 이 관용구의 이름은 하스켈의 새로운 유형 키워드에서 유래했습니다.

    이 관용구의 일반적인 용도 중 하나는 고아 규칙을 우회하고 별칭이 지정된 유형에 대한 특성 구현을 정의하는 것입니다. 예를 들어, 다음 코드는 바이트 벡터를 16진수로 표시하는 새 유형을 정의합니다.

    struct Hex(Vec<u8>);
    
    impl std::fmt::Display for Hex {
      fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.0.iter().try_for_each(|b| write!(f, "{:02x}", b))
      }
    }
    
    println!("{}", Hex((0..32).collect()));
    // => 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f

    새로운 타입 관용구는 효율적입니다: 컴퓨터 메모리에서 16진수 타입의 표현은 Vec<u8>과 동일합니다. 그러나 동일한 표현에도 불구하고 컴파일러는 새 유형을 Vec<u8>에 대한 강력한 별칭으로 취급하지 않습니다. 예를 들어, 외부 벡터를 재할당하지 않고는 Vec<Hex>Vec<Vec<u8>>로 안전하게 변환하거나 다시 변환할 수 없습니다. 또한 바이트를 복사하지 않고는 &Vec<u8>&Hex로 안전하게 강제 변환할 수 없습니다.

    fn complex_function(bytes: &Vec<u8>) {
      // … a lot of code …
    
      println!("{}", &Hex(bytes));        // That does not work.
      println!("{}", Hex(bytes.clone())); // That works but is slow.
    
      // … a lot of code …
    }

    전반적으로, 새로운 유형 관용구는 일류 언어 기능이 아니라 관습이기 때문에 누수가 있는 추상화입니다. 하스켈이 이 문제를 어떻게 해결했는지 궁금하다면 Simon Peyton Jones의 '하스켈의 안전한 제로 비용 강제성'(Safe, Zero-Cost Coercions in Haskell) 강연을 시청하는 것을 추천합니다.

    보기 및 번들(Views and bundles)

    프로그래머는 구조체 필드를 설명하거나 함수에 인수를 전달할 때마다 해당 필드/인수가 객체여야 하는지 참조여야 하는지 결정해야 합니다. 아니면 런타임에 결정하는 것이 가장 좋은 방법일까요? 정말 많은 의사 결정이 필요합니다! 안타깝게도 때로는 최적의 선택이 없을 때도 있습니다. 이러한 경우, 우리는 이를 악물고 필드 유형이 약간 다른 동일한 유형의 여러 버전을 정의합니다.

    Rust의 대부분의 함수는 참조로 인수를 받아 결과를 독립된 객체로 반환합니다. 물론 예외도 많이 있습니다. 복사본을 만드는 것이 저렴하거나 함수가 입력을 효율적으로 재사용하여 결과를 생성할 수 있는 경우 값으로 인수를 전달하기도 합니다. 일부 함수는 인자 중 하나에 대한 참조를 반환합니다. 이 패턴은 매우 일반적이어서 새로운 용어를 정의하는 것이 도움이 될 수 있습니다. 저는 수명 매개변수 보기가 있는 입력 유형을 호출하는데, 이는 데이터를 검사하는 데 최적이기 때문입니다. 일반 출력 유형은 독립적이기 때문에 번들이라고 부릅니다.

    다음 코드 조각은 (더 이상 사용되지 않는) Lucet WebAssembly 런타임에서 가져온 것입니다.

    /// A WebAssembly global along with its export specification.
    /// The lifetime parameter exists to support zero-copy deserialization
    /// for the `&str` fields at the leaves of the structure.
    /// For a variant with owned types at the leaves, see `OwnedGlobalSpec`.
    pub struct GlobalSpec<'a> {
        global: Global<'a>,
        export_names: Vec<&'a str>,
    }
    
    …
    
    /// A variant of `GlobalSpec` with owned strings throughout.
    /// This type is useful when directly building up a value to be serialized.
    pub struct OwnedGlobalSpec {
        global: OwnedGlobal,
        export_names: Vec<String>,
    }

    작성자는 두 가지 사용 사례를 지원하기 위해 GlobalSpec 데이터 구조를 복제했습니다:

    -> GlobalSpec<'a>는 코드 작성자가 바이트 버퍼에서 구문 분석하는 뷰 객체입니다. 이 뷰의 개별 필드는 버퍼의 관련 영역을 가리킵니다. 이 표현은 GlobalSpec 유형의 값을 수정하지 않고 검사해야 하는 함수에 유용합니다.
    -> OwnedGlobalSpec은 번들로, 다른 데이터 구조에 대한 참조를 포함하지 않습니다. 이 표현은 GlobalSpec 타입의 값을 구성하고 이를 전달하거나 컨테이너에 넣는 함수에 유용합니다.
    자동 메모리 관리 기능이 있는 언어에서는 하나의 유형 선언으로 GlobalSpec<'a>의 효율성과 소유된GlobalSpec의 다용도성을 결합할 수 있습니다.

    구도가 아플 때(When composition hurts)

    Rust에서는 작은 조각으로 작동하는 프로그램을 구축하는 것이 어려울 수 있습니다.

    개체 구성(Object composition)

    프로그래머는 두 개의 서로 다른 객체가 있을 때 이를 하나의 구조로 결합하고자 하는 경우가 많습니다. 쉬운 일인가요? Rust에서는 그렇지 않습니다.

    다른 객체인 Snapshot<'a>를 제공하는 메서드가 있는 객체 Db가 있다고 가정해 보겠습니다. 스냅샷의 수명은 데이터베이스의 수명에 따라 달라집니다.

    struct Db { /* … */ }
    
    struct Snapshot<'a> { /* … */ }
    
    impl Db { fn snapshot<'a>(&'a self) -> Snapshot<'a>; }

    왜 우리가 이런 이상한 욕망을 가지고 있는지 궁금하다면, rocksdb_iterator.rs 모듈의 코멘트를 읽어보세요. 스냅샷과 함께 데이터베이스를 번들링하고 싶지만 Rust는 이를 허용하지 않습니다.

    // There is no way to define the following struct without
    // contaminating it with lifetimes.
    struct DbSnapshot {
      snapshot: Snapshot<'a>, // what should 'a be?
      db: Arc<Db>,
    }

    Rust 사람들은 이 배열을 "형제 포인터"라고 부릅니다. 형제 포인터는 Rust의 안전 모델을 약화시키기 때문에 안전 코드에서 형제 포인터를 금지합니다.

    객체, 값 및 참조 섹션에서 설명한 것처럼 참조된 객체를 수정하는 것은 일반적으로 버그입니다. 이 경우 스냅샷 객체는 DB 객체의 물리적 위치에 따라 달라질 수 있습니다. DbSnapshot을 전체적으로 이동하면 db 필드의 물리적 위치가 변경되어 스냅샷 객체의 참조가 손상됩니다. Arc를 이동해도 Db 개체의 위치가 변경되지 않는다는 것을 알고 있지만, 이 정보를 rustc에 전달할 방법이 없습니다.

    Db 스냅샷의 또 다른 문제는 필드 소멸 순서가 중요하다는 것입니다. Rust가 형제 포인터를 허용하는 경우 필드 순서를 변경하면 스냅샷의 소멸자가 소멸된 db 객체의 필드에 액세스하려고 시도하는 정의되지 않은 동작이 발생할 수 있습니다.

    패턴 매칭은 상자를 통해 볼 수 없습니다.(Pattern matching cannot see through boxes)

    Rust에서는 Box, Arc, String, Vec와 같은 박스형 유형에서는 패턴 일치를 수행할 수 없습니다. 재귀 데이터 유형을 정의할 때 박스형을 피할 수 없기 때문에 이 제한은 종종 문제를 일으킵니다.

    예를 들어 문자열 벡터를 일치시켜 보겠습니다.

    let x = vec!["a".to_string(), "b".to_string()];
    match x {
    //    - help: consider slicing here: `x[..]`
        ["a", "b"] => println!("OK"),
    //  ^^^^^^^^^^ pattern cannot match with input type `Vec<String>`
        _ => (),
    }

    첫째, 벡터를 일치시킬 수 없고 슬라이스만 일치시킬 수 있습니다. 다행히도 컴파일러는 일치 표현식에서 x를 x[..]로 대체해야 한다는 쉬운 해결책을 제시합니다. 한 번 시도해 봅시다.

    let x = vec!["a".to_string(), "b".to_string()];
    match x[..] {
    //    ----- this expression has type `[String]`
        ["a", "b"] => println!("OK"),
    //   ^^^ expected struct `String`, found `&str`
        _ => (),
    }

    보시다시피, 상자 한 레이어를 제거하는 것만으로는 컴파일러를 만족시킬 수 없습니다. 또한 벡터 내부의 문자열 박스를 해제해야 하는데, 이는 새 벡터를 할당하지 않고는 불가능합니다:

    let x = vec!["a".to_string(), "b".to_string()]; // We have to allocate new storage. 
    let x_for_match: Vec<_> = x.iter().map(|s| s.as_str()).collect(); 
    match &x_for_match[..] { 
        ["a", "b"] => println!("OK"), // this compiles 
        _ => (), }

    Rust에서 코드 5줄로 레드-블랙 트리의 밸런스( balancing Red-Black trees)를 맞추는 일은 이제 잊으세요.

    고아 규칙(Orphan rules)

    Rust는 고아 규칙(orphan rules)을 사용하여 형이 형질을 구현할 수 있는지 여부를 결정합니다. 일반적이지 않은 유형의 경우, 이러한 규칙은 특성이나 유형을 정의하는 상자 외부에서 해당 유형의 특성을 구현하는 것을 금지합니다. 즉, 특성을 정의하는 패키지는 유형을 정의하는 패키지에 종속되어야 하거나 그 반대의 경우도 마찬가지입니다.


    Rust의 고아 규칙은 특성 구현이 특성을 정의하는 상자 또는 유형을 정의하는 상자에 존재하도록 요구합니다. 상자는 별도의 상자, 화살표-상자 종속성을 나타냅니다.

    이러한 규칙을 사용하면 컴파일러가 일관성을 쉽게 보장할 수 있으며, 이는 프로그램의 모든 부분에서 특정 유형에 대해 동일한 특성 구현을 볼 수 있도록 하는 현명한 방법입니다. 대신 이 규칙은 관련 없는 라이브러리의 특성과 유형을 통합하는 것을 상당히 복잡하게 만듭니다.

    한 가지 예로 테스트에서만 사용하려는 특성(예: proptest 패키지의 Arbitrary)을 들 수 있습니다. 컴파일러가 패키지에서 유형에 대한 구현을 도출하면 많은 타이핑을 절약할 수 있지만 프로덕션 코드는 프로테스트 패키지와 독립적이기를 원합니다. 완벽한 설정에서는 모든 임의 구현이 별도의 테스트 전용 패키지로 이동합니다. 안타깝게도 고아 규칙은 이러한 배열에 반대하기 때문에 총알을 깨물고 대신 수동으로 프로테스트 전략을 작성해야 합니다. 이 문제에 대한 해결 방법으로는 카고 기능 및 조건부 컴파일 사용과 같은 방법이 있지만 빌드 설정이 너무 복잡해지기 때문에 일반적으로 상용구를 작성하는 것이 더 나은 옵션입니다.

    From 및 Into와 같은 유형 변환 특성도 고아 규칙에서 문제가 됩니다. 처음에는 작게 시작하지만 컴파일 체인에서 병목 현상이 발생하는 XXX 유형 패키지를 자주 봅니다. 이러한 패키지를 더 작은 조각으로 분할하는 것은 멀리 떨어진 유형들을 서로 연결하는 복잡한 유형 변환의 웹 때문에 종종 어렵습니다. 고아 규칙을 사용하면 모듈 경계에서 이러한 패키지를 잘라내고 많은 지루한 작업 없이 모든 변환을 별도의 패키지로 옮길 수 있습니다.

    오해하지 마세요. 고아 규칙은 훌륭한 기본값입니다. 하스켈을 사용하면 고아 인스턴스를 정의할 수 있지만 프로그래머는 이 관행을 싫어합니다. 고아 규칙에서 벗어날 수 없다는 점이 저를 슬프게 합니다. 대규모 코드베이스에서는 큰 패키지를 작은 조각으로 분해하고 얕은 종속성 그래프를 유지하는 것이 허용 가능한 컴파일 속도로 가는 유일한 경로입니다. 고아 규칙은 종속성 그래프를 다듬는 데 방해가 되는 경우가 많습니다.

    두려움 없는 동시성은 거짓말입니다(Fearless concurrency is a lie)

    Rust 팀은 병렬 및 동시 프로그래밍과 관련된 일반적인 함정을 피할 수 있도록 도와준다는 의미로 '두려움 없는 동시성'(Fearless Concurrency)이라는 용어를 만들었습니다. 이러한 주장에도 불구하고 저는 Rust 프로그램에 동시성을 도입할 때마다 코티솔(cortisol) 수치가 올라갑니다.

    Deadlocks

    따라서 Safe Rust 프로그램이 교착 상태에 빠지거나 잘못된 동기화로 말도 안 되는 일을 하는 것은 완벽하게 "괜찮습니다". 물론 그런 프로그램은 그다지 훌륭하지는 않지만, Rust는 여기까지만 손을 잡아줄 수 있습니다.

    러스토노미콘, 데이터 레이스 및 레이스 조건

    Safe Rust는 데이터 경쟁이라는 특정 유형의 동시성 버그를 방지합니다. 동시성 Rust 프로그램에는 잘못된 동작을 하는 다른 많은 방법이 있습니다.

    제가 직접 경험한 동시성 버그의 한 종류는 데드락입니다. 이 종류의 버그에 대한 일반적인 설명은 두 개의 잠금과 두 개의 프로세스가 서로 반대 순서로 잠금을 획득하려고 시도하는 것과 관련이 있습니다. 그러나 사용하는 잠금이 재진입되지 않는 경우(Rust의 잠금은 재진입되지 않습니다) 하나의 잠금이 있는 것만으로도 교착 상태가 발생할 수 있습니다.

    예를 들어, 다음 코드는 동일한 잠금을 두 번 획득하려고 시도하기 때문에 버그가 있습니다. do_something과 helper_function이 크고 소스 파일에서 멀리 떨어져 있거나 드물게 실행 경로에서 helper_function을 호출하는 경우 이 버그를 발견하기 어려울 수 있습니다.

    impl Service {
      pub fn do_something(&self) {
        let guard = self.lock.read();
        // …
        self.helper_function(); // BUG: will panic or deadlock
        // …
      }
    
      fn helper_function(&self) {
        let guard = self.lock.read();
        // …
      }
    }

    RwLock::read(RwLock::read)에 대한 문서에는 현재 스레드가 이미 잠금을 보유하고 있으면 함수가 패닉에 빠질 수 있다고 언급되어 있습니다. 제가 얻은 것은 중단되는 프로그램뿐이었습니다.

    일부 언어는 동시성 툴킷에서 이 문제에 대한 해결책을 제공하려고 시도했습니다. Clang 컴파일러에는 경쟁 조건과 교착 상태를 감지할 수 있는 일종의 정적 분석을 가능하게 하는 스레드 안전 주석이 있습니다. 그러나 교착 상태를 피하는 가장 좋은 방법은 잠금을 사용하지 않는 것입니다. 이 문제에 근본적으로 접근하는 두 가지 기술은 소프트웨어 트랜잭션 메모리(하스켈, 클로저, 스칼라에서 구현됨)와 액터 모델(Erlang이 이를 완전히 수용한 최초의 언어임)입니다.

    파일 시스템은 공유 리소스입니다.(Filesystem is a shared resource)

    경로를 주소로 볼 수 있습니다. 그러면 경로를 나타내는 문자열은 포인터이고 경로를 통해 파일에 액세스하는 것은 포인터 역참조입니다. 따라서 파일 덮어쓰기로 인한 컴포넌트 간섭은 주소 충돌 문제로 볼 수 있습니다. 두 컴포넌트가 주소 공간의 겹치는 부분을 차지하는 것입니다.

    Eelco Dolstra, 순수 기능적 소프트웨어 배포 모델, p. 53

    Rust는 공유 메모리를 처리할 수 있는 강력한 도구를 제공합니다. 그러나 프로그램이 외부 세계와 상호 작용해야 하는 경우(예: 네트워크 인터페이스 또는 파일 시스템 사용)에는 우리 스스로가 알아서 해야 합니다. Rust는 이 점에서 대부분의 현대 언어와 유사합니다. 하지만 잘못된 보안 감각을 줄 수 있습니다.

    Rust에서도 경로는 원시 포인터라는 점을 기억하세요. 대부분의 파일 작업은 본질적으로 안전하지 않으며, 파일 액세스를 올바르게 동기화하지 않으면 (넓은 의미에서) 데이터 경합으로 이어질 수 있습니다. 예를 들어, 2023년 2월 현재에도 6년이나 된 동시성 버그가 Rustup에서 여전히 발생하고 있습니다.

    암시적 비동기 런타임(Implicit async runtimes

    물리학이 시간과 공간의 현실을 표현해야 한다는 생각과 멀리서 유령 같은 행동이 없는 현실을 표현해야 한다는 생각은 조화될 수 없기 때문에 나는 그 이론을 진지하게 믿을 수 없습니다.

    알버트 아인슈타인, 태어난 아인슈타인 편지, p. 158.

    제가 가장 좋아하는 Rust의 가치는 로컬 추론에 중점을 둔다는 점입니다. 함수의 타입 시그니처를 보면 함수가 무엇을 할 수 있는지 확실히 이해할 수 있습니다. 상태 변이는 가변성과 수명 주석 덕분에 명확합니다. 오류 처리는 유비쿼터스 결과 유형 덕분에 명시적이고 직관적입니다. 이러한 기능을 올바르게 사용하면 컴파일하면 작동하는 신비로운 효과를 얻을 수 있습니다. 그러나 Rust의 비동기 프로그래밍은 다릅니다.

    Rust는 비동기 함수를 정의하고 작성하기 위해 async/.await 구문을 지원하지만 런타임 지원은 제한적입니다. 여러 라이브러리(비동기 런타임이라고 함)가 운영 체제와 상호 작용하는 비동기 함수를 정의합니다. 가장 많이 사용되는 라이브러리는 tokio 패키지입니다.

    런타임의 일반적인 문제 중 하나는 암시적으로 인수를 전달하는 데 의존한다는 것입니다. 예를 들어, tokio 런타임을 사용하면 프로그램의 어느 지점에서든 동시 작업을 스폰할 수 있습니다. 이 기능이 작동하려면 프로그래머가 런타임 객체를 미리 구성해야 합니다.

    fn innocently_looking_function() {
      tokio::spawn(some_async_func());
      // ^
      // |
      // This code will panic if we remove this line. Spukhafte Fernwirkung!
    } //                                     |
      //                                     |
    fn main() { //                           v
      let _rt = tokio::runtime::Runtime::new().unwrap();
      innocently_looking_function();
    }

    이러한 암시적 인수는 컴파일 타임 오류를 런타임 오류로 전환합니다. 컴파일 오류여야 할 것이 디버깅 문제로 바뀝니다:

    -> 런타임이 명시적 인자인 경우 프로그래머가 런타임을 구성하고 인자로 전달하지 않으면 코드가 컴파일되지 않습니다. 런타임이 암시적일 경우, 코드가 정상적으로 컴파일될 수 있지만 주 함수에 마법의 매크로를 주석 처리하는 것을 잊어버리면 런타임에 충돌이 발생합니다.

    -> 서로 다른 런타임을 선택한 라이브러리를 혼합하는 것은 복잡합니다. 동일한 런타임의 여러 주요 버전이 관련된 경우 문제는 더욱 혼란스러워집니다. 비동기 Rust 코드를 작성한 제 경험은 비동기 워킹 그룹에서 수집한 현상 유지 이야기와 공감을 불러일으킵니다.

    어떤 사람들은 전체 호출 스택에 유비쿼터스 인수를 스레딩하는 것이 비인간적이라고 주장할 수 있습니다. 모든 인수를 명시적으로 전달하는 것이 확장성이 좋은 유일한 접근 방식입니다.

    함수에는 색상이 있습니다.(Functions have colors)

    이쯤 되면 합리적인 사람이라면 언어가 우리를 싫어한다고 생각할 수도 있습니다.

    밥 니스트롬, 당신의 기능은 어떤 색인가요?

    Rust의 async/.await 구문은 비동기 알고리즘의 구성을 단순화합니다. 그 대신 모든 함수를 파란색(동기화) 또는 빨간색(비동기화)으로 칠하는 등 상당히 복잡해집니다. 따라야 할 새로운 규칙이 있습니다:

    동기화 함수는 다른 동기화 함수를 호출하여 결과를 가져올 수 있습니다. 비동기 함수는 다른 비동기 함수를 호출하고 .await하여 결과를 가져올 수 있습니다.
    동기화 함수에서 비동기 함수를 직접 호출하고 대기할 수는 없습니다. 비동기 함수를 실행할 비동기 런타임이 필요합니다.
    비동기 함수에서 동기 함수를 호출할 수 있습니다. 하지만 조심하세요! 모든 동기화 함수가 똑같이 파란색은 아닙니다.
    일부 동기화 함수는 파일을 읽거나 스레드를 조인하거나 소파에서 스레드::슬립을 수행하는 등 은밀하게 보라색입니다. 이러한 보라색(차단) 함수를 빨간색(비동기) 함수에서 호출하면 런타임을 차단하고 비동기 문제를 해결하도록 동기를 부여한 성능 이점을 없애기 때문에 호출하고 싶지 않습니다.

    안타깝게도 보라색 함수는 비밀스럽기 때문에 호출 그래프에서 해당 함수의 본문과 다른 모든 함수의 본문을 검사하지 않고는 함수가 보라색인지 여부를 알 수 없습니다. 이러한 몸체는 진화하기 때문에 계속 주시하는 것이 좋습니다.

    여러 팀이 동기화 및 비동기 코드를 샌드위치하는 소유권을 공유하는 코드 기반이 있을 때 진정한 재미를 느낄 수 있습니다. 이러한 패키지는 시스템 부하가 충분히 증가하여 샌드위치에 또 다른 보라색 결함이 나타나 시스템이 응답하지 않을 때까지 기다리는 버그 사일로인 경향이 있습니다.

    하스켈이나 Go와 같이 녹색 스레드를 중심으로 런타임이 구축된 언어는 함수 색상의 확산을 방지합니다. 이러한 언어에서는 독립적인 컴포넌트로부터 동시 프로그램을 빌드하는 것이 더 쉽고 안전합니다.

    Conclusion

    사람들이 불평하는 언어와 아무도 사용하지 않는 언어, 두 가지 종류의 언어만 존재합니다.

    비야른 스트루스트럽

    Rust는 안전성에 대한 타협하지 않는 초점, 특성 시스템 설계, C++ 개념, 암시적 변환의 부재, 오류 처리에 대한 총체적인 접근 방식 등 많은 중요한 결정을 올바르게 내린 절제된 언어입니다. 이를 통해 실행 속도를 저하시키지 않으면서도 강력하고 메모리 안전성이 뛰어난 프로그램을 비교적 빠르게 개발할 수 있습니다.

    하지만, 특히 성능에는 거의 신경 쓰지 않고 테스트 코드와 같이 무언가를 빠르게 작동시키고자 할 때 우발적인 복잡성에 압도당하는 경우가 종종 있습니다. Rust는 프로그램을 더 작은 조각으로 분해하고 작은 조각으로 구성하는 작업을 복잡하게 만들 수 있습니다. Rust는 동시성 문제를 부분적으로만 제거합니다. 모든 문제에 완벽한 언어는 없습니다.

    이 글은 Reddit(Reddit.)에서 토론할 수 있습니다.

    728x90
Designed by Tistory.