가비지 컬렉터 제거: RAII 방식
게시 됨: 2022-03-11처음에는 C가 있었습니다. C에는 정적, 자동 및 동적의 세 가지 유형의 메모리 할당이 있습니다. 정적 변수는 소스 파일에 포함된 상수이며 크기가 알려져 있고 절대 변경되지 않으므로 그다지 흥미롭지 않습니다. 자동 할당은 스택 할당으로 생각할 수 있습니다. 어휘 블록이 입력될 때 공간이 할당되고 해당 블록이 종료될 때 해제됩니다. 그것의 가장 중요한 특징은 그것과 직접적인 관련이 있습니다. C99까지 자동으로 할당된 변수는 컴파일 시 크기를 알아야 했습니다. 이것은 모든 문자열, 목록, 맵 및 이들로부터 파생된 모든 구조가 동적 메모리의 힙에 있어야 함을 의미합니다.
동적 메모리는 malloc, realloc, calloc 및 free의 네 가지 기본 작업을 사용하여 프로그래머가 명시적으로 할당하고 해제했습니다. 이들 중 처음 두 개는 초기화를 전혀 수행하지 않으며 메모리에 cruft가 포함될 수 있습니다. 무료를 제외하고는 모두 실패할 수 있습니다. 이 경우 액세스가 정의되지 않은 동작인 null 포인터를 반환합니다. 가장 좋은 경우에는 프로그램이 폭발합니다. 최악의 경우 프로그램이 폭발하기 전에 가비지 데이터를 처리하면서 잠시 동안 작동하는 것처럼 보입니다.
이런 식으로 일을 하는 것은 일종의 고통스러운 일입니다. 왜냐하면 프로그래머는 위반 시 프로그램이 폭발하게 만드는 많은 불변량을 유지 관리하는 전적인 책임이 있기 때문입니다. 변수에 액세스하기 전에 malloc 호출이 있어야 합니다. 변수를 사용하기 전에 malloc이 성공적으로 반환되었는지 확인해야 합니다. 실행 경로에서 malloc당 정확히 하나의 무료 호출이 있어야 합니다. 0이면 메모리 누수가 발생합니다. 둘 이상이면 프로그램이 폭발합니다. 해제된 후에는 변수에 대한 액세스 시도가 없을 수 있습니다. 실제로 어떻게 보이는지 예를 들어 보겠습니다.
int main() { char *str = (char *) malloc(7); strcpy(str, "toptal"); printf("char array = \"%s\" @ %u\n", str, str); str = (char *) realloc(str, 11); strcat(str, ".com"); printf("char array = \"%s\" @ %u\n", str, str); free(str); return(0); } $ make runc gcc -oc cc ./c char * (null terminated): toptal @ 66576 char * (null terminated): toptal.com @ 66576그 코드는 간단하지만 이미 하나의 반패턴과 하나의 의심스러운 결정을 포함하고 있습니다. 실생활에서 바이트 수를 리터럴로 작성해서는 안 되며 대신 sizeof 함수를 사용하십시오. 유사하게, 우리는 char * 배열을 우리가 두 번 필요로 하는 문자열의 크기에 정확히 할당합니다(널 종료를 설명하기 위해 문자열 길이보다 하나 더 큼). 이는 상당히 비용이 많이 드는 작업입니다. 보다 정교한 프로그램은 더 큰 문자열 버퍼를 구성하여 문자열 크기를 늘릴 수 있습니다.
RAII의 발명: 새로운 희망
그 모든 수동 관리는 말할 것도 없이 불쾌했습니다. 80년대 중반에 Bjarne Stroustrup은 그의 새로운 언어인 C++에 대한 새로운 패러다임을 발명했습니다. 그는 그것을 Resource Acquisition Is Initialization이라고 불렀고 기본적인 통찰력은 다음과 같습니다. 컴파일러에 의해 적절한 시간에 자동으로 호출되는 생성자와 소멸자를 갖도록 개체를 지정할 수 있습니다. 이는 주어진 개체에서 메모리를 관리하는 훨씬 더 편리한 방법을 제공합니다. 이 기술은 메모리가 아닌 리소스에도 유용합니다.
이것은 C++에서 위의 예가 훨씬 더 깨끗하다는 것을 의미합니다.
int main() { std::string str = std::string ("toptal"); std::cout << "string object: " << str << " @ " << &str << "\n"; str += ".com"; std::cout << "string object: " << str << " @ " << &str << "\n"; return(0); } $ g++ -o ex_1 ex_1.cpp && ./ex_1 string object: toptal @ 0x5fcaf0 string object: toptal.com @ 0x5fcaf0수동 메모리 관리가 보이지 않습니다! 문자열 객체가 생성되고 오버로드된 메서드가 호출되며 함수가 종료되면 자동으로 소멸됩니다. 불행히도 동일한 단순성이 다른 합병증을 유발할 수 있습니다. 예를 좀 더 자세히 살펴보겠습니다.
vector<string> read_lines_from_file(string &file_name) { vector<string> lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines.push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); int count = read_lines_from_file(file_name).size(); cout << "File " << file_name << " contains " << count << " lines."; return 0; } $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.이 모든 것이 매우 간단해 보입니다. 벡터 라인은 채워지고 반환되고 호출됩니다. 그러나 성능에 신경을 쓰는 효율적인 프로그래머이기 때문에 이 점이 우리를 괴롭히는 것입니다. return 문에서 벡터는 소멸 직전에 재생 중인 값 의미로 인해 새 벡터로 복사됩니다.
이것은 더 이상 현대 C++에서 엄격하게 사실이 아닙니다. C++11에서는 원점이 유효하지만 지정되지 않은 상태로 남아 있는 이동 의미 체계의 개념을 도입했습니다. 반환 호출은 컴파일러가 의미 체계를 이동하도록 최적화하는 매우 쉬운 경우입니다. 더 이상 액세스하기 직전에 원본이 파괴된다는 것을 알고 있기 때문입니다. 그러나 이 예제의 목적은 왜 사람들이 80년대 후반과 90년대 초반에 많은 가비지 수집 언어를 발명했는지 보여주고 그 당시에는 C++ 이동 의미 체계를 사용할 수 없었습니다.
대용량 데이터의 경우 비용이 많이 들 수 있습니다. 이것을 최적화하고 포인터를 반환하자. 몇 가지 구문 변경 사항이 있지만 그 외에는 동일한 코드입니다.
사실 벡터는 값 핸들입니다. 힙에 있는 항목에 대한 포인터를 포함하는 비교적 작은 구조입니다. 엄밀히 말하면 단순히 벡터를 반환하는 것은 문제가 되지 않습니다. 큰 배열이 반환되는 경우 예제가 더 잘 작동합니다. 미리 할당된 배열로 파일을 읽으려는 시도는 무의미하므로 대신 벡터를 사용합니다. 비현실적으로 큰 데이터 구조라고 가정하십시오.
vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } $ make cpp && ./c++ makefile g++ -o c++ c++.cpp Segmentation fault (core dumped)아야! 이제 라인이 포인터이므로 자동 변수가 광고된 대로 작동하고 있음을 알 수 있습니다. 벡터는 범위를 벗어나면 소멸되고 포인터는 스택의 전방 위치를 가리키는 상태로 남습니다. 분할 오류는 단순히 잘못된 메모리에 대한 액세스 시도이므로 실제로 예상해야 합니다. 그래도 우리는 어떻게든 함수에서 파일의 줄을 다시 가져오고 싶고, 자연스럽게 변수를 스택에서 힙으로 옮기는 것입니다. 이것은 new 키워드로 수행됩니다. 파일의 한 줄을 간단히 편집할 수 있습니다. 여기서 줄을 정의합니다.
vector<string> * lines = new vector<string>; $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.불행히도 이것이 완벽하게 작동하는 것처럼 보이지만 여전히 결함이 있습니다. 바로 메모리 누수입니다. C++에서 힙에 대한 포인터는 더 이상 필요하지 않은 후 수동으로 삭제해야 합니다. 그렇지 않은 경우 해당 메모리는 마지막 포인터가 범위를 벗어나면 사용할 수 없게 되며 프로세스가 종료될 때 OS에서 관리할 때까지 복구되지 않습니다. 관용적 현대 C++는 여기에서 원하는 동작을 구현하는 unique_ptr을 사용합니다. 포인터가 범위를 벗어날 때 가리키는 개체를 삭제합니다. 그러나 그 동작은 C++11까지 언어의 일부가 아니었습니다.
이 예에서는 다음과 같이 쉽게 수정할 수 있습니다.
vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines = new vector<string>; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); vector<string> * file_lines = read_lines_from_file(file_name); int count = file_lines->size(); delete file_lines; cout << "File " << file_name << " contains " << count << " lines."; return 0; }불행히도 프로그램이 장난감 규모를 넘어서 확장됨에 따라 정확히 포인터를 삭제해야 하는 위치와 시기에 대해 추론하기가 더 어려워집니다. 함수가 포인터를 반환할 때 지금 포인터를 소유하고 있습니까? 작업이 끝나면 직접 삭제해야 합니까, 아니면 나중에 한 번에 모두 해제될 일부 데이터 구조에 속합니까? 어떤 면에서는 틀리고 메모리 누수가 나고 다른 면에서는 틀리면 문제의 데이터 구조를 손상시켰고 다른 것들은 이제 더 이상 유효하지 않은 포인터를 역참조하려고 시도하기 때문에 손상될 수 있습니다.
"쓰레기 수집가 속으로, 플라이보이!"
가비지 컬렉터는 새로운 기술이 아닙니다. 그들은 Lisp를 위해 John McCarthy가 1959년에 발명했습니다. 1980년 Smalltalk-80과 함께 가비지 수집이 주류가 되기 시작했습니다. 그러나 1990년대는 기술의 진정한 개화를 나타냈습니다. 1990년에서 2000년 사이에 Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml과 같은 일종의 가비지 수집을 사용하는 많은 언어가 출시되었습니다. , C#이 가장 잘 알려져 있습니다.
가비지 컬렉션이란? 간단히 말해서 수동 메모리 관리를 자동화하는 데 사용되는 일련의 기술입니다. C 및 C++와 같은 수동 메모리 관리 기능이 있는 언어용 라이브러리로 자주 사용되지만 이를 필요로 하는 언어에서 훨씬 더 일반적으로 사용됩니다. 가장 큰 장점은 프로그래머가 단순히 메모리에 대해 생각할 필요가 없다는 것입니다. 모두 추상화되어 있습니다. 예를 들어 위의 파일 읽기 코드에 해당하는 Python은 다음과 같습니다.
def read_lines_from_file(file_name): lines = [] with open(file_name) as fp: for line in fp: lines.append(line) return lines if __name__ == '__main__': import sys file_name = sys.argv[1] count = len(read_lines_from_file(file_name)) print("File {} contains {} lines.".format(file_name, count)) $ python3 python3.py makefile File makefile contains 38 lines. 행 배열은 처음 할당될 때 존재하게 되며 호출 범위에 복사하지 않고 반환됩니다. 타이밍이 불확실하기 때문에 해당 범위를 벗어난 후 언젠가 가비지 수집기에 의해 정리됩니다. 흥미로운 점은 Python에서 비메모리 리소스에 대한 RAII가 관용적이지 않다는 것입니다. 허용됩니다. with 블록을 사용하는 대신 단순히 fp = open(file_name) 을 작성하고 GC가 나중에 정리하도록 할 수 있습니다. 그러나 권장되는 패턴은 가능한 경우 컨텍스트 관리자를 사용하여 결정적인 시간에 릴리스할 수 있도록 하는 것입니다.

메모리 관리를 추상화하는 것이 좋지만 비용이 있습니다. 참조 카운팅 가비지 수집에서 모든 변수 할당 및 범위 종료는 참조를 업데이트하는 데 약간의 비용이 듭니다. mark-and-sweep 시스템에서는 예측할 수 없는 간격으로 모든 프로그램 실행이 중지되고 GC가 메모리를 정리합니다. 이것은 종종 stop-world 이벤트라고 합니다. 두 시스템을 모두 사용하는 Python과 같은 구현은 두 가지 불이익을 모두 받습니다. 이러한 문제는 성능이 중요하거나 실시간 애플리케이션이 필요한 경우에 가비지 수집 언어의 적합성을 감소시킵니다. 다음과 같은 장난감 프로그램에서도 성능 저하가 발생하는 것을 볼 수 있습니다.
$ make cpp && time ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines. real 0m0.016s user 0m0.000s sys 0m0.015s $ time python3 python3.py makefile File makefile contains 38 lines. real 0m0.041s user 0m0.015s sys 0m0.015sPython 버전은 C++ 버전보다 거의 3배의 실시간 시간이 걸립니다. 이러한 차이가 모두 가비지 수집으로 인한 것은 아니지만 여전히 상당합니다.
소유권: RAII Awakens
그럼 끝인가요? 모든 프로그래밍 언어는 성능과 프로그래밍 용이성 중에서 선택해야 합니까? 아니요! 프로그래밍 언어 연구는 계속되고 있으며 우리는 차세대 언어 패러다임의 첫 번째 구현을 보기 시작했습니다. 특히 흥미로운 것은 Rust라는 언어입니다. Python과 같은 인체 공학 및 C와 같은 속도를 약속하면서 댕글링 포인터, null 포인터 등을 불가능하게 만들며 컴파일되지 않습니다. 어떻게 그러한 주장을 할 수 있습니까?
이러한 인상적인 주장을 허용하는 핵심 기술을 차용 검사기라고 하며, 컴파일 시 실행되는 정적 검사기로 이러한 문제를 일으킬 수 있는 코드를 거부합니다. 그러나 의미에 대해 너무 깊이 들어가기 전에 전제 조건에 대해 이야기해야 합니다.
소유권
C++의 포인터에 대한 논의에서 우리는 소유권 개념을 다루었습니다. 소유권 개념은 대략적으로 "누가 이 변수를 삭제할 책임이 있는지"를 의미합니다. Rust는 이 개념을 공식화하고 강화합니다. 모든 변수 바인딩은 바인딩하는 리소스의 소유권을 가지며 차용 검사기는 리소스의 전체 소유권을 가진 바인딩이 정확히 하나만 있는지 확인합니다. 즉, Rust Book의 다음 스니펫은 컴파일되지 않습니다.
let v = vec![1, 2, 3]; let v2 = v; println!("v[0] is: {}", v[0]); error: use of moved value: `v` println!("v[0] is: {}", v[0]); ^Rust의 할당에는 기본적으로 이동 의미가 있습니다. 소유권을 이전합니다. 유형에 의미를 복사하는 것이 가능하며 이는 숫자 프리미티브에 대해 이미 수행되었지만 이례적인 일입니다. 이 때문에 코드의 세 번째 줄에서 v2는 문제의 벡터를 소유하고 더 이상 v로 액세스할 수 없습니다. 이것이 왜 유용한가요? 모든 리소스에 정확히 한 명의 소유자가 있는 경우 범위를 벗어나는 단일 순간도 있으며 이는 컴파일 타임에 결정할 수 있습니다. 이것은 차례로 Rust가 가비지 수집기를 사용하거나 프로그래머가 수동으로 무엇이든 해제하도록 요구하지 않고 범위에 따라 결정적으로 리소스를 초기화 및 파괴하여 RAII의 약속을 이행할 수 있음을 의미합니다.
이것을 참조 카운팅 가비지 수집과 비교하십시오. RC 구현에서 모든 포인터에는 적어도 두 가지 정보가 있습니다. 가리키는 개체와 해당 개체에 대한 참조 수입니다. 개체는 해당 개수가 0에 도달하면 소멸됩니다. 이렇게 하면 포인터의 메모리 요구 사항이 두 배가 되고 개수가 자동으로 증가, 감소 및 확인되기 때문에 포인터 사용에 약간의 비용이 추가됩니다. Rust의 소유권 시스템은 참조가 부족할 때 객체가 자동으로 소멸된다는 동일한 보장을 제공하지만 런타임 비용 없이 그렇게 합니다. 각 개체의 소유권이 분석되고 컴파일 타임에 소멸 호출이 삽입됩니다.
차용
이동 의미 체계가 데이터를 전달하는 유일한 방법이라면 함수 반환 유형이 매우 복잡해지고 빨라질 것입니다. 두 벡터를 사용하여 정수를 생성하는 함수를 작성하고 나중에 벡터를 파괴하지 않는 함수를 작성하려면 반환 값에 포함해야 합니다. 기술적으로 가능하지만 다음을 사용하는 것은 끔찍합니다.
fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) { // do stuff with v1 and v2 // hand back ownership, and the result of our function (v1, v2, 42) } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let (v1, v2, answer) = foo(v1, v2);대신 Rust는 차용이라는 개념을 가지고 있습니다. 다음과 같이 동일한 함수를 작성할 수 있으며 벡터에 대한 참조를 차용하여 함수가 종료되면 소유자에게 다시 제공합니다.
fn foo(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 { // do stuff 42 } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let answer = foo(&v1, &v2);v1 및 v2는 fn foo가 반환된 후 소유권을 원래 범위로 반환하고 범위를 벗어나 포함된 범위가 종료될 때 자동으로 소멸됩니다.
여기서 언급할 가치가 있는 것은 Rust Book에서 매우 간결하게 설명하는 차용에 대한 제한이 컴파일 시간에 차용 검사기에 의해 시행된다는 것입니다.
모든 차용은 소유자의 범위보다 크지 않은 범위에서 지속되어야 합니다. 둘째, 다음 두 종류의 차용 중 하나 또는 다른 것을 가질 수 있지만 둘 다 동시에 가질 수는 없습니다.
리소스에 대한 하나 이상의 참조(&T)
정확히 하나의 변경 가능한 참조(&mut T)
이것은 데이터 경쟁에 대한 Rust 보호의 중요한 측면을 형성하기 때문에 주목할 만합니다. 컴파일 시간에 주어진 리소스에 대한 다중 변경 가능한 액세스를 방지함으로써 리소스에 먼저 도착한 스레드에 따라 달라지기 때문에 결과가 불확실한 코드를 작성할 수 없도록 보장합니다. 이것은 반복자 무효화 및 무료 후 사용과 같은 문제를 방지합니다.
실용적인 용어의 차용 검사기
이제 Rust의 일부 기능에 대해 알았으므로 이전에 본 것과 동일한 파일 줄 카운터를 구현하는 방법을 살펴보겠습니다.
fn read_lines_from_file(file_name: &str) -> io::Result<Vec<String>> { // variables in Rust are immutable by default. The mut keyword allows them to be mutated. let mut lines = Vec::new(); let mut buffer = String::new(); if let Ok(mut fp) = OpenOptions::new().read(true).open(file_name) { // We enter this block only if the file was successfully opened. // This is one way to unwrap the Result<T, E> type Rust uses instead of exceptions. // fp.read_to_string can return an Err. The try! macro passes such errors // upwards through the call stack, or continues otherwise. try!(fp.read_to_string(&mut buffer)); lines = buffer.split("\n").map(|s| s.to_string()).collect(); } Ok(lines) } fn main() { // Get file name from the first argument. // Note that args().nth() produces an Option<T>. To get at the actual argument, we use // the .expect() function, which panics with the given message if nth() returned None, // indicating that there weren't at least that many arguments. Contrast with C++, which // segfaults when there aren't enough arguments, or Python, which raises an IndexError. // In Rust, error cases *must* be accounted for. let file_name = env::args().nth(1).expect("This program requires at least one argument!"); if let Ok(file_lines) = read_lines_from_file(&file_name) { println!("File {} contains {} lines.", file_name, file_lines.len()); } else { // read_lines_from_file returned an error println!("Could not read file {}", file_name); } } 소스 코드에서 이미 주석 처리된 항목 외에도 다양한 변수의 수명을 살펴보고 추적할 가치가 있습니다. file_name 및 file_lines 는 main()이 끝날 때까지 지속됩니다. 소멸자는 C++의 자동 변수와 동일한 메커니즘을 사용하여 추가 비용 없이 호출됩니다. read_lines_from_file 을 호출할 때 file_name 은 해당 기간 동안 해당 함수에 변경 불가능하게 대여됩니다. read_lines_from_file 내에서 buffer 는 동일한 방식으로 작동하며 범위를 벗어나면 소멸됩니다. 반면, lines 은 지속되며 성공적으로 main 으로 반환됩니다. 왜요?
가장 먼저 주목해야 할 것은 Rust가 표현식 기반 언어이기 때문에 반환 호출이 처음에는 같지 않을 수 있다는 것입니다. 함수의 마지막 줄이 후행 세미콜론을 생략하면 해당 표현식이 반환 값입니다. 두 번째는 반환 값이 특별한 처리를 받는다는 것입니다. 그들은 적어도 함수의 호출자만큼 살기를 원한다고 가정합니다. 마지막 참고 사항은 관련된 이동 의미로 인해 Ok(lines) 를 Ok(file_lines) 로 변환하는 데 필요한 복사본이 없으며 컴파일러는 변수가 적절한 메모리 비트를 가리키도록 합니다.
"마지막에야 비로소 RAII의 진정한 힘을 깨닫게 됩니다."
수동 메모리 관리는 컴파일러가 발명된 이후로 프로그래머가 피하는 방법을 고안해 낸 악몽입니다. RAII는 유망한 패턴이었지만 C++에서는 몇 가지 이상한 해결 방법 없이는 힙 할당 개체에 대해 작동하지 않았기 때문에 제대로 작동하지 않았습니다. 결과적으로 90년대에는 성능을 희생하더라도 프로그래머의 삶을 더 즐겁게 하도록 설계된 가비지 수집 언어가 폭발적으로 증가했습니다.
그러나 그것이 언어 디자인의 마지막 단어는 아닙니다. 새롭고 강력한 소유권 및 차용 개념을 사용하여 Rust는 RAII 패턴의 범위 기반을 가비지 수집의 메모리 보안과 병합합니다. 다른 어떤 언어에서도 볼 수 없는 안전을 보장하면서 세상을 멈추기 위해 가비지 수집기를 요구하지 않고 이 모든 것이 가능합니다. 이것이 시스템 프로그래밍의 미래입니다. 결국, "오류하는 것은 인간이지만 컴파일러는 결코 잊지 않습니다."
Toptal 엔지니어링 블로그에 대한 추가 정보:
- WebAssembly/Rust 튜토리얼: 완벽한 피치 오디오 처리
- Java에서 메모리 누수 찾기
