라이브 함수

라이브 함수 (Live Functions)

D 언어의 라이브 함수(@live) 기능은 포인터의 소유권(ownership)과 빌림(borrowing)을 프로그램이 정적으로 검사할 수 있게 해 주는 실험적 기능이에요. 이 장에서는 소유권과 빌림이 무엇인지, @live 속성이 어떤 규칙으로 포인터 상태를 추적하는지 그리고 어떤 오류를 잡아낼 수 있는지를 하나씩 살펴봅니다.

출처: https://dlang.org/spec/ob.html

본문

실험적이며 변경될 수 있는 기능입니다. 활성화하려면 -preview=dip1021을 사용하세요.

소유권 (Ownership)

메모리 객체를 가리키는 포인터가 하나뿐이라면, 그 포인터가 바로 그 메모리 객체의 *소유자(owner)*가 돼요. 소유자가 하나뿐이면 객체의 메모리를 관리하는 일이 단순해집니다. 또한 여러 스레드 사이에서 그 메모리 객체에 대한 접근을 동기화하는 것도 아주 쉬워져요. 그 객체는 그 단일 포인터를 쥐고 있는 스레드가 아니면 접근할 수 없으니까요.

이 개념은 포인터로 서로 연결된 메모리 객체들의 그래프로 확장할 수 있어요. 외부에서 그 그래프로 연결되는 포인터가 하나뿐이라면, 그 포인터가 그래프 안의 모든 메모리 객체의 소유자가 됩니다.

그래프의 소유자가 더 이상 필요 없어지면, 그것이 가리키는 메모리 객체 그래프도 더 이상 필요 없으므로 안전하게 해제할 수 있어요. 만약 소유자 자체가 더 이상 사용되지 않고(즉 더 이상 라이브(live) 상태가 아니고) 소유된 메모리 객체들이 해제되지 않았다면, 오류로 진단할 수 있습니다.

따라서 다음과 같은 오류들을 정적으로 검출할 수 있어요:

int* allocate();    // allocate a memory object
void release(int*); // deallocate a memory object

@live void test()
{
    auto p = allocate();
}   // error: p is not disposed of

@live void test()
{
    auto p = allocate();
    release(p);
    release(p); // error: p was already disposed of
}

@live void test()
{
    int* p = void;
    release(p); // error, p does not have a defined value
}

@live void test()
{
    auto p = allocate();
    p = allocate(); // error: p was not disposed of
    release(p);
}

@live 속성이 붙은 함수들은 소유자 포인터의 상태를 추적함으로써 이런 종류의 오류를 진단할 수 있어요.

빌림 (Borrowing)

포인터의 소유권 상태를 추적하는 일은, 소유자로부터 포인터의 소유권을 잠시 빌려오는(borrow) 능력을 추가함으로써 안전하게 확장할 수 있습니다. *빌린 쪽(borrower)*이 그 포인터 값을 여전히 사용하고 있는 동안(즉 라이브 상태인 동안)에는 소유자가 그 포인터를 사용할 수 없어요. 빌린 쪽이 더 이상 라이브 상태가 아니면, 소유자가 다시 그 포인터를 사용할 수 있게 됩니다. 어느 순간에도 빌린 쪽은 하나만 라이브 상태일 수 있어요.

그 대신, 여러 빌린 쪽 포인터가 동시에 존재할 수는 있는데, 그 경우는 모두 읽기 전용(const 또는 immutable) 데이터를 가리킬 때뿐이에요. 즉, 그중 어떤 것도 가리키는 메모리 객체를 수정할 수 없을 때만 가능합니다.

이런 것을 통틀어 소유권/빌림(Ownership/Borrowing) 시스템이라고 해요. 한 문장으로 정리하면 이렇습니다:

프로그램의 어느 시점에서나, 각 메모리 객체에 대해, 그것을 가리키는 변경 가능한(mutable) 라이브 포인터가 정확히 하나 존재하거나, 아니면 그것을 가리키는 모든 라이브 포인터가 읽기 전용이다.

@live 속성 (@live attribute)

@live 속성으로 표시된 함수 선언은 소유권/빌림 규칙을 지키는지 검사돼요. 검사는 다른 의미 분석(semantic processing)이 끝난 뒤에 실행되며, 검사 결과는 코드 생성에 영향을 주지 않습니다.

포인터가 GC(Garbage Collector)로 메모리를 할당받았는지 아니면 다른 저장소 할당자(storage allocator)를 사용했는지는 OB(Ownership/Borrowing)에 아무런 영향을 주지 않아요. 둘을 구분하지 않고 동일하게 취급합니다.

클래스 참조(class reference)는 GC로 할당되거나 스택 위에 scope 클래스로 할당된다고 가정하며, 추적 대상이 아닙니다.

@live 함수가 @live가 아닌 함수를 호출하면, 그 호출되는 함수가 @live와 호환되는 인터페이스를 제공할 것이라고 기대합니다(다만 이는 검사되지 않아요). 반대로 @live가 아닌 함수가 @live 함수를 호출하면, 전달되는 인자들이 @live 규약을 따를 것이라고 기대합니다.

또한 null 포인터 또는 null일 가능성이 있는 포인터를 역참조(dereference)하려는 시도는 검출하지 않아요. null이 아닌 포인터로 타입에 주석을 다는 방법이 현재 없기 때문에 이 검사는 실질적으로 불가능합니다.

추적되는 포인터 (Tracked Pointers)

추적되는 포인터는 @live 함수 안에서 this, 함수 매개변수 또는 지역 변수로 선언된 포인터뿐이에요. 다른 함수의 변수는 추적하지 않습니다. 심지어 @live 함수의 변수라도요. 다른 함수와의 상호작용 분석은 전적으로 그 함수의 시그니처에 의존하며 내부 구현에는 의존하지 않기 때문입니다. const인 매개변수도 추적하지 않습니다.

포인터 상태 (Pointer States)

추적되는 각 포인터는 다음 상태 중 하나에 있어요:

Undefined (정의되지 않음)

포인터가 유효하지 않은 상태예요. 이런 포인터를 역참조하는 것은 오류입니다.

Owner (소유자)

소유자는 메모리 객체 그래프에 대한 유일한 포인터예요. Owner 포인터는 보통 scope 속성을 갖지 않아요. scope 속성이 있는 포인터라도 추적되는 포인터에서 파생되지 않은 표현식으로 초기화되면 Owner가 됩니다.

Owner 포인터가 다른 Owner 포인터에 할당되면, 기존의 것은 Undefined 상태로 들어가요.

void consume(int* o); // o is owner

@live int* f(int* p) // p is owner
{
    writeln(*p);
    // transfer ownership to `consume`
    consume(p);
    // p is now undefined
    //writeln(*p); // error

    int* q = new int; // q is owner
    writeln(*q);
    p = q; // transfer ownership
    // q is now undefined
    //writeln(*q); // error
    writeln(*p);
    return p;
}

Borrowed (빌림)

Borrowed 포인터는 메모리 객체 그래프에 대한 유일한 라이브 포인터가 잠시 되는 포인터예요. owner 포인터나 다른 borrowed 포인터로부터의 할당을 통해 그 상태로 들어가며, 마지막으로 사용될 때까지 그 상태를 유지합니다.

Borrowed 포인터는 반드시 scope여야 하고, 변경 가능한(mutable) 것을 가리키는 포인터여야 해요. 변경 가능한 scope 포인터 함수 매개변수는 Borrowed 포인터입니다.

void consume(int* o); // o is owner
void borrow(scope int* b); // b is borrowed

@live void g(scope int* p) // p is borrowed
{
    //consume(p); // error, p is not owner
    borrow(p);

    // lend p to q
    int* q = p; // q is inferred as scope
    // <-- using p here would end q's lifetime
    writeln(*q);
    // lifetime of q ends before p is used
    writeln(*p); // OK
}

Readonly (읽기 전용)

Readonly 포인터는 Owner나 Borrowed 포인터로부터 값을 얻어와요. Readonly 포인터가 라이브 상태인 동안에는, 그 Owner로부터는 Readonly 포인터만 얻을 수 있어요. Readonly 포인터는 반드시 scope여야 하고, 또한 변경 가능한 것(mutable)을 가리키는 포인터가 아니어야 합니다.

@live void h(scope int* p)
{
    // acquire 2 read only pointers
    const q = p;
    const r = q;
    // <-- borrowing or using p here would end q and r's lifetime

    // both q and r are live
    writeln(*q);
    writeln(*r);
    // using p ends all its read only pointer lifetimes
    writeln(*p);
    //writeln(*q); // error
}

생존 기간 (Lifetimes)

Borrowed나 Readonly 포인터 값의 생존 기간(lifetime)은 Owner나 다른 Borrowed 포인터로부터 값을 할당받는 순간에 시작해서, 그 값을 마지막으로 읽는 순간에 끝나요.

이것을 *비-어휘적 생존 기간(Non-Lexical Lifetimes)*이라고도 부릅니다.

포인터 상태 전이 (Pointer State Transitions)

포인터에 다음 연산 중 하나가 수행되면 상태가 바뀝니다:

  • 포인터를 위한 저장 공간이 할당될 때(예: 스택 위의 지역 변수), 포인터는 Undefined 상태에 놓입니다

  • 초기화(initialization) — 할당(assignment)과 동일하게 취급됩니다

  • 할당(assignment) — 소스와 대상 포인터는 각각의 상태와, 타입 및 저장 클래스에 따라 상태가 바뀝니다

  • out 함수 매개변수로 전달 — (함수가 반환된 뒤에 상태가 바뀌며) 초기화와 동일하게 취급됩니다

  • ref로 함수 매개변수에 전달 — 매개변수의 저장 클래스와 타입에 따라 Borrow 또는 Readonly에 대한 할당으로 취급됩니다

  • 함수에서 반환될 때

  • 값으로 함수 매개변수에 전달 — 해당 매개변수에 대한 할당으로 취급됩니다

  • 중첩 함수에 클로저 변수로 암시적으로 참조(ref)로 전달될 때

  • 포인터의 주소를 취할 때 — 그 주소를 받는 쪽에 대한 할당으로 취급됩니다

  • 메모리 객체 그래프의 어떤 부분의 주소를 취할 때 — 그 주소를 받는 쪽에 대한 할당으로 취급됩니다

  • 메모리 객체 그래프의 어떤 부분에서 포인터 값을 읽을 때 — 그 포인터를 받는 쪽에 대한 할당으로 취급됩니다

  • 제어 흐름이 합쳐질 때 — 각 변수의 상태를 각 분기(edge)에서 갖고 있는 상태를 기준으로 조정(reconcile)합니다

빌린 쪽도 소유자가 될 수 있어요 (Borrowers can be Owners)

Borrower는 포인터가 아닌 것으로부터 초기화되면 Owner로 간주됩니다.

@live void uhoh()
{
    scope p = malloc();  // p is considered an Owner
    scope const pc = malloc(); // pc is not considered an Owner
} // dangling pointer pc is not detected on exit

예외 (Exceptions)

이 분석은 예외가 발생하지 않는다고 가정합니다.

@live void leaky()
{
    auto p = malloc();
    pitcher();  // throws exception, p leaks
    free(p);
}

한 가지 해결책은 scope(exit)를 사용하는 거예요:

@live void waterTight()
{
    auto p = malloc();
    scope(exit) free(p);
    pitcher();
}

또는 RAII 객체를 사용하거나 nothrow 함수만 호출해도 됩니다.

지연 매개변수 (Lazy Parameters)

지연(lazy) 매개변수는 고려하지 않습니다.

메모리 풀 섞기 (Mixing Memory Pools)

서로 다른 메모리 풀을 혼동하는 경우:

void* xmalloc(size_t);
void xfree(void*);

void* ymalloc(size_t);
void yfree(void*);

auto p = xmalloc(20);
yfree(p);  // should call xfree() instead

는 검출되지 않습니다.

이것은 타입별 풀을 사용함으로써 완화할 수 있어요:

U* umalloc();
void ufree(U*);

V* vmalloc();
void vfree(V*);

auto p = umalloc();
vfree(p);  // type mismatch

그리고 @live 함수에서 void*로의 암시적 변환(implicit conversion)을 비활성화하는 것도 도움이 될 수 있습니다.

가변 인자 함수 인자 (Variadic Function Arguments)

가변 인자 함수(예: printf)에 전달되는 인자들은 소비(consumed)되는 것으로 간주됩니다.

참고 자료 (References)

  1. Race-free Multithreading: Ownership

더 알아보기 (Learn more)