23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
Memory-safe in C에서 GCC 확장을 활용한 GLib의 자원 관리에 대한 글을 보고,
이 문제에 대한 Modern C++의 답은 무엇인지 아래의 file descriptor 예제를 통해 가볍게 알아보는 글을 작성해 봤습니다.
우리가 socket을 사용하여 통신한다고 가정했을 때, 먼저 socket() 함수를 호출하여 OS에게 socket 하나를 요청하고 이를 file descriptor(sk_fd) 형태로 받습니다.
다 사용한 file descriptor는 close() 함수를 호출하여 반환합니다.
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <iostream>
#include <system_error>
#include <stdexcept>
namespace {
int create_socket(int domain, int type, int protocol) {
int sk_fd = socket(domain, type, protocol);
if (sk_fd == -1) {
throw std::system_error(errno, std::system_category(),
"failed to create socket");
}
return sk_fd;
}
void close_fd(int fd) {
if (fd != -1) {
close(fd);
std::cout << "file descriptor closed\n";
}
}
}
int main() try {
auto sk_fd = create_socket(AF_INET, SOCK_DGRAM, 0);
// do something with sk_fd
close_fd(sk_fd);
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}위 예제를 컴파일하여 실행하면 아래와 같이 file descriptor를 통해 socket을 반환함을 알 수 있습니다.
file descriptor closedFile descriptor를 사용하는 부분이 문제 없이 원하는 대로 모두 수행되면 좋겠지만,
현실에서는 실행시간에 예외 상황이 발생하여 할당받은 자원(socket)을 반환해야 하는 경우가 있습니다.
이런 예외 상황을 제대로 처리하지 않으면 자원 누수(resource leak)가 발생합니다.
int main() try {
auto sk_fd = create_socket(AF_INET, SOCK_DGRAM, 0);
// do something with sk_fd
{
throw std::runtime_error("a runtime error occurred");
}
close_fd(sk_fd);
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}위와 같이 main() 함수를 변경하고 예제를 다시 컴파일하여 실행하면 아래와 같이 file descriptor를 제대로 반환하지 않음을 확인할 수 있습니다.
a runtime error occurred이러한 예외 상황이 발생했을 때 자원을 반환하는 방법의 하나는, 예상할 수 있는 예외 상황을 if 문이나 try와 catch block으로 처리하는 것입니다.
하지만, 이 방법의 경우 관리해야 하는 자원의 수가 늘어나고 자원별로 할당/반환 시점이 달라지면서 예외 상황 처리의 복잡도가 증가하게 됩니다.
C++에서는 오래전부터 RAII와 stack unwinding을 통해 이 문제를 해결해 왔습니다.
우리는 아래 예제처럼 std::string을 사용하면서 memory 반환을 고민하지 않습니다. 이는 std::string이 RAII를 기반으로 작성되었기 때문입니다.
#include <iostream>
#include <string>
int main() {
auto s = std::string{"Hello, world!"};
std::cout << s << '\n';
// Who should free up the character array in `s`?
}그렇다면 RAII가 뭘까요?
RAII는 C++의 객체(object, class나 struct의 instance)가 가지고 있는 언어적 특성을 활용한 것입니다.
객체는 생성자(constructor)를 호출하여 만듭니다.
객체가 scope를 벗어나면 파괴자(destructor)를 호출합니다.
위 예제에서 s를 정의할 때 std::string의 생성자는 인자로 받은 문자열을 저장할 memory를 할당받으며,
s가 scope(main() 함수)를 벗어날 때 std::string의 파괴자는 할당받은 memory를 반환합니다.
즉, 자원을 어떻게 할당/반환하는지 이해하고 있는 class를 정의하고, 할당받은 자원을 해당 class의 객체로 관리하는 것입니다.
만약 s를 정의한 후 예외(exception)가 발생(throw)해도 s의 파괴자가 제대로 호출될까요? 이 질문에 대한 답이 stack unwinding입니다.
간략히 설명하면, stack unwinding은 예외를 처리할 catch block을 만날 때까지 call stack을 따라 올라가면서 stack에 있는 모든 (automatic) 변수의 파괴자를 호출하는 작업입니다.
이제 우리는 RAII(와 stack unwinding)을 활용하여 file descriptor 예제를 아래와 같이 개선할 수 있습니다.
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <iostream>
#include <system_error>
#include <stdexcept>
class FileDescriptor {
public:
FileDescriptor(int fd = -1) : fd_{fd} {}
~FileDescriptor() {
if (fd_ != -1) {
close(fd_);
std::cout << "file descriptor closed\n";
}
}
operator int() const { return fd_; }
private:
int fd_;
};
namespace {
int create_socket(int domain, int type, int protocol) {
int sk_fd = socket(domain, type, protocol);
if (sk_fd == -1) {
throw std::system_error(errno, std::system_category(),
"failed to create socket");
}
return sk_fd;
}
}
int main() try {
auto sk_fd = FileDescriptor{create_socket(AF_INET, SOCK_DGRAM, 0)};
// do something with sk_fd
{
throw std::runtime_error("a runtime error occurred");
}
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}FileDescriptor class를 정의하고, 이 class를 사용하여 file descriptor를 관리합니다.
기존의 close_fd() 함수의 역할을 ~FileDescriptor()(파괴자)가 대신합니다.
(다양한 자원이 file descriptor 형태로 표현되고, 그러한 자원을 할당받는 방법도 각각 다르므로, 생성자는 할당받은 자원의 file descriptor를 인자로 받는 것으로 구현되었습니다.)
위 예제를 컴파일하여 실행하면 이제는 아래와 같이 file descriptor를 제대로 반환함을 확인할 수 있습니다.
file descriptor closed
a runtime error occurred여기까지 보면 자원 관리 측면에서는 아무런 문제가 없어 보이지만,
사실 객체가 관리하는 자원이 객체의 생명주기와 관계 없이 scope를 벗어날 수 있어야 효율적인 프로그램을 작성할 수 있습니다.
아래와 같이 긴 문자열을 생성하고 return 하는 함수가 문자열을 std::string으로 관리한다고 생각해 보겠습니다.
#include <iostream>
#include <string>
namespace {
std::string generate_string() {
auto s = std::string{"this is a very loooooooooong string"};
return s;
}
}
int main() {
auto generated_s = generate_string();
std::cout << generated_s << '\n';
}s는 generate_string() 함수가 종료될 때 해당 scope를 벗어나면서 파괴자가 호출되므로, 그 전에 해당 문자열을 복사하여 generated_s로 관리해야 합니다.
C++는 이렇게 객체를 복사하는 개념을 복사 생성자(copy constructor)와 복사 대입 연산자(copy assignment operator)로 지원합니다.
하지만 std::string은 위의 복사 과정에서 memory의 할당, 복사, 반환이 한 번씩 필요합니다. 이러한 비효율은 "복사"라는 개념만으로는 객체가 관리하는 자원이 scope를 벗어날 수 없어서 발생합니다.
(사실 위의 경우 NRVO(named return value optimization)에 의해 복사가 발생하지 않지만, 최적화 없이 복사를 한다고 가정하겠습니다.)
File descriptor 예제를 위와 비슷하게 수정한 후 컴파일하여 실행하면 어떻게 될까요? (GCC로 컴파일 시 -fno-elide-constructors option을 넘겨서 NRVO를 하지 않도록 합니다.)
// ...
FileDescriptor get_socket() {
auto sk_fd = FileDescriptor{create_socket(AF_INET, SOCK_DGRAM, 0)};
// set options on sk_fd
return sk_fd;
}
}
int main() try {
auto sk_fd = get_socket();
// do something with sk_fd
{
throw std::runtime_error("a runtime error occurred");
}
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}아래처럼 동일한 file descriptor가 두 번 반환됨을 확인할 수 있습니다.
이는 FileDescriptor class가 기본 복사를 수행하여 fd_를 값으로 복사했기 때문입니다. (나중에 FileDescriptor는 복사할 수 없도록 만들 예정입니다.)
file descriptor closed
file descriptor closed
a runtime error occurred위에서 살펴본 복사로 인한 비효율 혹은 의미상 복사가 어색하거나 불가능한 상황을 해결하는 방법은 객체가 관리하는 자원을 scope 밖에 있는 다른 객체로 이동(move)하는 것입니다.
예를 들어 memory의 경우 pointer를 값으로 복사하고 기존 pointer 변수를 nullptr로 설정하는 것과 같은 방식으로 말이지요.
함수에서 객체를 반환하는 것 말고 이러한 "이동"이 필요한 상황은 무엇이 있을까요? 당장 생각나는 것으로는 queue를 통해 producer에서 consumer로 자원을 넘기는 경우가 있겠네요.
이렇게 객체가 관리하는 자원을 다른 객체로 이동하는 개념을 Modern C++에서는 move semantics라고 합니다.
핵심만 설명하면, move semantics는 이동 생성자(move constructor)와 이동 대입 연산자(move assignment operator)를 지원하고 사용자가 객체의 복사(copy)와 이동(move) 중 무엇을 할지 선택할 수 있도록 하는 것입니다.
아래의 std::string 예제를 통해 복사와 이동의 차이를 살펴보겠습니다.
#include <iostream>
#include <string>
#include <utility>
int main() {
auto s = std::string{"Hello, world!"};
// copy
auto c = s;
std::cout << "c: " << c << '\n';
std::cout << "s: " << s << '\n';
// move
auto m = std::move(s);
std::cout << "m: " << m << '\n';
std::cout << "s: " << s << '\n';
}위 예제를 컴파일하여 실행하면 아래처럼 마지막에 "Hello, world!"가 아닌 빈 줄을 출력함을 확인할 수 있습니다. 이는 복사 대신 이동을 하겠다는 것을 std::move()로 표현하여 s가 관리하는 문자열을 m으로 이동했기 때문입니다.
c: Hello, world!
s: Hello, world!
m: Hello, world!
s: File descriptor 예제에서 복사를 막고 이동을 추가한 모습은 아래와 같습니다.
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <iostream>
#include <system_error>
#include <stdexcept>
class FileDescriptor {
public:
FileDescriptor(int fd = -1) : fd_{fd} {}
~FileDescriptor() {
if (fd_ != -1) {
close(fd_);
std::cout << "file descriptor closed\n";
}
}
// not copyable
FileDescriptor(const FileDescriptor&) = delete;
FileDescriptor& operator=(const FileDescriptor&) = delete;
FileDescriptor(FileDescriptor&& other) {
fd_ = other.fd_;
other.fd_ = -1;
std::cout << "move constructed\n";
}
FileDescriptor& operator=(FileDescriptor&& other) {
if (fd_ != -1) {
close(fd_);
std::cout << "file descriptor closed (move)\n";
}
fd_ = other.fd_;
other.fd_ = -1;
std::cout << "move assigned\n";
return *this;
}
operator int() const { return fd_; }
private:
int fd_;
};
namespace {
int create_socket(int domain, int type, int protocol) {
int sk_fd = socket(domain, type, protocol);
if (sk_fd == -1) {
throw std::system_error(errno, std::system_category(),
"failed to create socket");
}
return sk_fd;
}
FileDescriptor get_socket() {
auto sk_fd = FileDescriptor{create_socket(AF_INET, SOCK_DGRAM, 0)};
// set options on sk_fd
return sk_fd;
}
}
int main() try {
auto sk_fd = get_socket();
// do something with sk_fd
{
throw std::runtime_error("a runtime error occurred");
}
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}먼저, 복사 생성자와 복사 대입 연산자에 = delete를 붙여서 명시적으로 복사를 할 수 없도록 만들었습니다.
그리고 이동 생성자(FileDescriptor(FileDescriptor&& other))와 이동 대입 연산자(FileDescriptor& operator=(FileDescriptor&& other))를 정의했습니다.
(여기서 복사와 이동의 차이는 대상 객체를 &(l-value reference)로 참조하는가 &&(r-value reference)로 참조하는가입니다. 일단 이 글에서는 이렇게만 알고 넘어가고, l-value와 r-value에 대해서는 다음에 기회가 되면 다른 글에서 설명하겠습니다.)
이동 생성자와 이동 대입 연산자는 대상 객체(get_socket() 함수의 sk_fd)의 file descriptor를 생성 혹은 할당할 객체(main() 함수의 sk_fd)에 저장하고 대상 객체가 해당 file descriptor를 (-1을 저장하여) 잊어버리도록 합니다.
(추가로 이동 대입 연산자에서는 할당할 객체에 관리중인 file descriptor가 있는지 확인하고, 있다면 이를 반환합니다.)
이렇게 file descriptor를 이동하여 대상 객체가 scope를 벗어나서 파괴자가 호출되더라도 아무것도 하지 않도록 합니다.
위 예제를 컴파일하여 실행하면 아래처럼 file descriptor가 정상적으로 반환됨을 볼 수 있습니다. (GCC로 컴파일 시 -fno-elide-constructors option을 넘겨서 NRVO를 하지 않도록 합니다.)
move constructed
file descriptor closed
a runtime error occurred여기까지 몇 가지 예제를 통해 C++에서 자원 누수 없이 효율적인 자원 관리를 위한 방법을 살펴봤으며, 그 과정에서 RAII부터 move semantics까지 짚어봤습니다.
C++의 표준 라이브러리는 예제에서 사용한 std::string 뿐만 아니라 memory 관리에 활용할 수 있는 std::unique_ptr나 std::shared_ptr와 같이
자원 누수 없이 자원을 관리할 수 있도록 다양한 자료구조와 도구를 제공합니다.
만약 여러분이 표준 라이브러리만으로 표현할 수 없는 자원을 관리해야 하는 상황을 맞닥뜨렸을 때,
이 글에서 우리가 직접 FileDescriptor class를 작성한 것이 도움이 되었으면 하는 바람입니다.
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.