Docker + C++ IDE 개발환경 꾸려보기

Windows / macOS 용 docker를 이용하면 VM을 따로 띄우지 않아도 로컬 환경을 리눅스 개발 환경처럼 쓸 수 있다.
전에도 한 얘기지만, 툴은 가능하면 해당 OS 용의 GUI 도구를 활용하고 싶은게 내 심정.

그래서 (?) 요즘 나오는 좋을 툴들을 써서 Windows / macOS를 쓰는 기기에서 docker + linux 개발환경을 해당 OS 용 IDE 로 개발하게 꾸려보았다. (컴파일은 linux 컨테이너, 디버거 UI는 호스트 OS의 IDE를 쓴단 소리)

Windows

Widows 의 VisualStudio 확장 기능으로 linux  개발을 지원하는 Visual C++ Tools for Linux 라는게 있다. 지난 2016-09-06 에 업데이트.

이걸 써서 같은 컴퓨터에 띄운 docker 컨테이너에 접근하면, 디버거까지 붙여서 돌려볼 수 있다.
하재승 군의 crow 예제 하나를 디버거 붙여보면 잘 돈다.

vs-debugging

다만 고칠게 좀 많다:

  • Docker for Windows 의 volume 설정이 좀 이상한 것
  • 권한을 추가로 주지 않으면 잘 안 도는 것 (디버깅)
  • 빌드/디버깅/빌드 결과물을 한땀한땀 입력해야하는 것 (…)

macOS

macOS 용으론 Facebook 의 Nuclide 가 있다. 여기서도 crow 예제를 돌려보았다.mac-nuclide-io

macOS 에 띄운 docker 컨테이너에서 프로세스를 띄우고, lldb (GDB가 아니라…) 를 띄워 디버깅하는게 가능하다. 다만 의존성 걸린게 많아서 내가 만들어본 역대 최대 크기 (1.1 GiB) 짜리 docker 이미지가 나오더라; 용량은 차치하고, Docker for macOS 쪽이 volume 지원같은게 낫긴하지만 이쪽도 문제는 한 바가지긴 하다.

  • 설정을 한땀한땀 넣어야한다. (VS 쪽보다 아주 약간 편함)
  • 깔아야하는게 한 가득 (atom, nuclide, npm, clang, llvm, lldb, … 그리고 clang, llvm, lldb 에 대한 python 래퍼)
  • 테스트 한다고 썼던 ubuntu 16.04 에선 라이브러리 내의 경로가 잘못 박힌게 많아서 고쳐써야할 부분도 많다 (ubuntu 14.04 쪽도 크게 다르지 않으리라 생각)

이런 내용을 포함해서 이번 주 수요일 (2016-09-28) 에 넥슨 코리아 지하에서 기술 세미나를 합니다. 궁금하신 분은 와서 보시길.

Interop Madness

클라이언트 서버간 암호화  관련해서 개념 증명 + 몇 가지 성능 측정을 해야해서 몇 가지 언어 + 플랫폼에서 libsodium 1 을 호출해봤다. 그 와중에 겪은 일 정리.

C++

이쪽은 평범한 C/C++ 인터페이스니 문제 없이 끝.

Python

위에서 구현한 부분을 클라이언트 쪽 구현을 (빨리) 만들어서 테스트하는 작업을 했다.

여기서부터 문제가 생기기 시작. Ubuntu 16.04 (xenial) 이 제공하는 libsodium 래퍼가 python-nacl 2 인데, 써보니 내가 쓰려는 함수가 여럿 없다. 특히 crypto_stream* 류 함수가 없더라. 그래서 다른 래퍼가 있나하고 PyPI 에서 몇 가지 써보고는 빠른 포기.

이제 cffi 를 쓸 타이밍. C 코드로 정의한 함수를 변환해서 만든  .py 파일로 동적 라이브러리 (.so, .dll, .dylib) 에서 가져오는 방식을 썼다. 3 예~전에 ctypes 로 로딩할 때 괴로웠던 부분이 확실히 줄어들었다. ABI 대신 API 수준에서 접근하는 차이는 정말 컸다. cdata의 buffer 인터페이스도 쓸만했다.

cffi 로 libsodium 에서 필요한 함수들을 가져다 쓰기 시작한 이후로는 금방 일이 정리.

…하지만 이 시점에선 어떤 비참한 미래가 기다리는지 모르고 있었다. (?)

C#

(나로써는) 슬프게도, 클라이언트 쪽 구현 중 하나는 C#으로 해야한다. (Unity3d + mono) 게다가 이건 여러 플랫폼을 지원해야 한다:

  • Unity Editor: Windows, MacOS
  • Android: 다행히도 ARMv7만. Unity가 ARMv6는 지원하지 않는다 (?)
  • iOS: 기기 + 시뮬레이터에서 모두 돌아야 한다.

Windows는 (내가) 당장 쓸 머신이 없어서 일단 미뤄두고, 나머지에 대해서 잘 도는지 시험하기 시작. 꼬박 2일 걸린 것 같다 — 생각해보니 절반은 C++ 기반인 cocos2d 랑 같이 처리하는 것 때문이었던 것 같기도?

우선 각 플랫폼 별로 크로스 빌드를 시도…해야하나 했는데, libsoidum 저장소에 플랫폼 별 빌드 스크립트가 있다. 4 iOS/Android 쪽에 내가 쓰려던 함수 중 몇 개가 빠져서 컴파일 플래그를 조금 고친 것 말고는 별 일 없이 진행. 그리고 Unity 에서 (native) 동적 라이브러리를 어떻게 가져가나 고민했는데 경험 많은 동료분이 설명해주셔서 매우 간단한게 통과.

대략,

  • DllImport 로  C에 있는 함수들을 CLR 쪽에 노출하고
  • C# 코드로 이 함수들을 래핑하고
  • 다시 이 함수들을 써서 실제 개발자가 쓸 고수준 API로 바꾸자

라는 방향으로 진행했다. 에디터(=랩탑 위)에서 잘 도는걸 보고 android  기기에 올리기 시작. 왠걸, 초기화 (=sodium_init()) 은 성공했고 몇몇 초기화도 했는데, 그 이후에 호출하는 함수에서 크래시 (SIGSEGV).  (당연하게도) 콜 스택은 native code에서 죽는 것.

원인을 추측하기 시작:

  • C 함수 원형을 잘못보고 옮겼는지 확인 시작. C는 타입 무시하고 심볼 이름만 보니 내가 잘못 옮겼다면 크래시할 수도 있으니 우선 여길 의심하고 C 함수 원형과 하나씩 대조. 별 이상 없어 보였다 (불행의 단초)
  • 크로스 컴파일을 잘못했는가 확인: 생각해보니 C/C++ 로 올렸을 때는 잘 돌았다 (…)

…대략 정신이 멍해져오는데 다시 함수 원형을 보다가 불현듯 깨달은 사실:

C 쪽의 size_t 를 C#의 ulong 으로 지정했는데 Unity + ARMv7 은 32bit ABI를 쓰니 uint 여야하는 것. Orz.

그래서 이걸 iOS / Android 플래그로 ulong / uint 처리하게 했다가, iOS도 아직 32bit인게 남아있는 걸 보고는 (iPhone 5 및 그 이전 + iPhone 5C) UIntPtr 로 처리하고 강제 캐스팅하게 수정해서 마무리했다. 그러고나니 잘 돌더라.

 

3줄 요약

  • ABI 맞추기는 삽질이다. 어떻게든 API 수준으로 해결할 수 있는 법을 찾자 — python의 cffi 처럼. 아니면 기계적으로 처리할 방법을 찾아야?
  • (부득이하게) ABI를 맞춰야 한다면 가능한 arch 목록을 잘 확인하자. C/C++에서 편했던 platform dependent type들이 대재앙
  • 여러 플랫폼 지원하는 라이브러리는 정말 인생에 도움이 된다. 잘 쓰고나면 널리 알려줍시다 (?)

 


  1. libsodium 페이지. 현 시점에 적합한 대칭키 암호화 + AEAD, ECC 함수들, system random source 를 래핑한 함수, side channel 공격을 피하는 비교, 해시, … 등등을 제공하는 잘 만든 라이브러리. 인터페이스도 보고 바로 쓰는게 그다지 어렵지 않다. 
  2. GitHub 저장소. libsodium 이 fork(?)한 NaCl 을 래핑한 것 같은 이름이지만 libsodium 을 래핑한다. 
  3. API 수준에서, 소스 코드 밖에서 정의 부분을 참고해서 작성했다. 
  4. 저장소의 dist-build 디렉터리 의 스크립트들이 이에 해당한다. 

C++11: scoped_lock + “짜증나는 파싱”

boost::thread 에는 scoped_lock 이라는 블럭 스코프 동안 유효한 락이 있다. 멀티스레드 프로그래밍 하는 사람들은 유용하게 쓰고 있으리라 생각한다.
이 클래스를 엉터리로 쓰고 있는 다음 코드를 보자. main 에서 미리 mutex 에 락을 걸고 들어가기 때문에, scoped_lock 이 락을 걸지 못해서 데드락에 빠질 것처럼 보인다.

#include <iostream>

#include <boost/thread/mutex.hpp>


struct foo {
  boost::mutex mutex_;

  void Run() {
    boost::mutex::scoped_lock(mutex_);
    std::cout << "Should not be reached" << std::endl;
  }
};


int main (int argc, char** argv) {
  foo bar;
  bar.mutex_.lock();  // 미리 mutex 를 획득; 데드락을 의도함
  bar.Run();
  bar.mutex_.unlock();
  return 0;
}

하지만 그렇지 않다.

“Should not be reached” 를 출력하고 프로그램은 정상 종료한다. 왜 그런가 설명하자면, C++의 가장 짜증나는 파싱 규칙 (C++’s (most) vexing parse) 때문.

C++의 가장 짜증나는 파싱 (C++ ‘s most vexing parse) 은 아주 거칠게 말하자면, 선언으로 해석할 수 있는건 선언으로 해석한다 란 의미다. 그래서 boost::mutex::scoped_lock(mutex_)mutex_ 란 이름의 scoped_lock선언 한다. mutex_ 에 대한 scoped_lock 을 초기화하는게 아니라.
문법적으론 둘 다 가능해 보이지만, 실제로는 선언으로 우선 해석하기에 foo::Run() 은 락을 획득하지 않는다.

대충 이런 느낌의 코드가 지난 주에 회사 코드베이스에서 나와서 “나는 리뷰 때 무얼했는가”라면서 괴로워함 ㅠㅠ

C++11 이후에는 이 문제가 아주 간단히 풀린다. “Effective Modern C++”의 Item 7에서 나오는 것처럼, 괄호가 아니라 중괄호를 쓰면 이런 문제가 없다. 변수 선언인지 초기화인지를 의도에 따라 적당한 문법을 쓸 수 있다. 다음과 같이 바꾸면 의도한 대로 데드락에 빠진다.

struct foo {
  boost::mutex mutex_;

  void Run() {
    boost::mutex::scoped_lock {mutex_};  // 초기화 목록으로 변경; 하지만 정상적인 동작은 아니다
    std::cout << "Should not be reached" << std::endl;
  }
};

그런 의미에서 C++11 이후를 씁시다! (…)

Updated : 아래 dkim 님이 코멘트한대로 이건 의도한 동작이 되는 것은 아니다.
C++에서 이름 없는 변수의 생명 주기는 expression 이 끝나는 순간 소멸한다. 그래서 C++11의 초기화 문법을 써봐야 데드락이 걸리긴하지만, (원래 의도일) 스코프 락을 제대로 획득한 상황은 아니다.

C++14: lambda 함수의 캡처 목록

C++11 lambda 함수에 값을 전달하는 방법은 복사/참조 두 가지 뿐이라서 expression 을 전달하진 못한다. lambda를 아주 거칠게 묘사하면 특정 인자를 받는 functor struct를 자동으로 생성하고 이거의 타입 추론을 자동으로 해주는 정도다. 그러니 생성자에 해당하는 lambda 캡처 목록에 “생성자에 해당하는게 있는데 왜 expression은 생성자에 못 넘기고, 변수 혹은 변수의 참조만 넘길까” 라고 생각하는건 매우 타당한 의문인 것.

예를 들어 C++11 에서 매우 거대한 std::unordered_map<k , v> 를 캡처한다고 치면 정말 복사를 해야 한다. 혹은 std::shared_ptr< ...> 를 써서 넘기거나, 아니면 “Effective Modern C++” 의 item 32에서 다루는 std::bind 트릭을 써야 한다.

unordered_map<int, string> m = large_object_factory();
// 타이머 이벤트를 건다. 수 초 후에 m 가지고 계산 수행
SetTimer(10000, [m]() {
   /* m 가지고 뭔가 수행 */
});

위처럼하면 m 을 통채로 복사해야해서 별로 효율적이지 않다. std::shared_ptr 를 쓰면,

auto pm = make_shared<unordered_map<int, string>>( \
    large_object_factory());
// 타이머 이벤트를 건다. 수 초 후에 m 가지고 계산 수행
SetTimer(10000, [pm]() {
   /* pm 가지고 뭔가 수행 */
});

shared_ptr 생성/복사 오버헤드 정도로 처리할 수 있다. 위 코드가 동작은 하지만, 간결하진 않다. C++03 에서 C++1x 로 넘어올 때의 간결해진 코드 베이스를 생각하면 정말 맘에 안든다.

그래서 캡처 문법을 일반화해서 C++14 에는 lambda 구문에 capture init-list 라는 개념이 생겼다. 해당 드래프트에서는 generalized lambda capture 라고 부르는데, up-value를 캡처하는게 변수 이름만으로 하는게 아니라 일반적인 expression 을 다룰 수 있게 되었기 때문. expression 이 되니까 std::move 를 써서 값을 옮겨버리는 것도 가능하다.

즉, 다음과 같은 코드가 가능해진다. 잘 보면 생성자처럼도 보인다. (?)

unordered_map<int, string> m = large_object_factory();
// 타이머 이벤트를 건다. 수 초 후에 m 가지고 계산 수행
SetTimer(10000, [_m = std::move(m)]() {
   /* _m 가지고 뭔가 수행 */
});

즉, C++11 에서는 혼용할 수 없는 lambda 캡처 목록에서 expression 사용이 가능해진 것 (즉, rvalue 참조를 써서 효율적으로 복사하는게 가능해진 것).

C++1x: std::map 초기화하기

C++03 을 쓰던 시절에 가졌던 불만 중 하나는 std::map 처럼 dictionary 타입을 쓸 때 선언 즉시 초기화하기 번잡한 것. 순서가 중요하지 않을 때 성능이 떨어지는 것도 그렇고.
예를 들어, “Jan” -> 1, “Feb” -> 2, …, “Dec” -> 12 와 같은 관계를 표현하고 싶을 때 C++03 에서 std::map 에 간단히 표현할 방법은 없다. 추가적인 초기화함수를 선언해서 써야한다.

const static std::map<std::string, int> kMonths
    = InitializeMonthDict();

static std::map<std::string, int> InitializeMonthDict() {
  std::map<std::string, int> rv;
  rv.insert(std::make_pair("Jan", 1));
  rv.insert(std::make_pair("Feb", 2));
  // ...
  return rv;
}

혹은 이걸 초기화한 값을 갖는 operator overloading된 구조체/클래스를 선언하거나.

C++03이더라도 boost 를 쓸 수 있다면 boost::assign map_list_of 를 써서 다음과 같이 쓸 순 있다:

#include <boost/assign/list_of.hpp>
#include <map>

const static std::map<std::string, int> kMonths
    = boost::map_list_of("Jan", 1)("Feb, 2")
      // ...
      ("Dec", 12);

이런 식으로 표현하는게 요즘 언어들 (예를 들어 Python 혹은 JSON 표현형) 과 비교하면 너무 번잡하다. 예를 들어 Python으로 표현하면:

kMonths = {
  "Jan": 1,
  "Feb": 2,
  # ...
  "Dec": 12,
}

하지만 C++11 을 쓰면 이건 초기화 목록을 쓰는 간단한 문법으로 바뀐다:

#include <map>  // unordered_map 을 쓸수도?

const static std::map<std::string, int> kMonths
    = {{"Jan", 1"}, {"Feb", 2}, /* ... */ {"Dec", 12},};

덕분에 요즘은 테이블 룩업 류 (예를 들어 enum -> 함수 맵) 처리할 땐 편하게 짜고 있는 듯 하다.

boost::scoped_ptr 를 기억하십니까?

어제 스마트 포인터 관련 글을 썼는데 여기에서 boost::scoped_ptr 는 다루지 않았다.
그건 scoped_ptr 가 반쯤 구현된 unique_ptr 라서. 두 개의 스마트 포인터의 차이는,

  • scoped_ptr 는 지금의 unique_ptr 처럼 오직 1명의 소유자 인 코드를 짤 때 유용하다.
  • unique_ptr 는 move 를 적극적으로 활용해서 만들어진 표준 라이브러리다. (C++11)
  • unique_ptrrelease() 라는 소유권을 포기하는 멤버 함수를 제공한다.

정도다. 그래서 mutable 함수인 release() 만 못쓰게 막으면 되기 때문에 — 즉, const unique_ptr 를 쓰면 되기 때문에, scoped_ptr 의 유용성이 없어진 것.
비슷한 이유로, Google C++ Styleguide도 처음엔 scoped_ptr 좋아요 였다가, C++03 호환성 때문이 아니면 unique_ptr 를 써라 로 바뀌었다.

내 취향으로는 명시적인 release() 멤버 함수가 있다/없다로 구분하는게 더 좋지만 (…), 표준에는 없는 (boost에만 있음) boost::scoped_ptr 를 권하는 건 이젠 어려울 듯…

delete 쓸 일 없는 C++

지난 몇 달간 모 게임의 서버를 새로 만든다고 다시 C++ 을 한창 썼는데, 그 간의 경험을 정리하는 글을 몇 개 써볼까한다.

우선 delete 쓸 일 없는 C++. 적어도 전통적으로 쓰던 “메모리 해제”의 의미delete 를 쓰는 일은 이제 거의 없는 듯 하다.
메모리를 할당하는 순간 (아주 과격하게 말하면) std::unique_ptr 혹은 std::shared_ptr 중 하나에 저장하면 된다. 선택할 때 판단할 근거도 간단하다.

  • 이 메모리를 어느 객체가 가지고 있는지 명확한가 => std::unique_ptr
  • (반대로) 이 메모리를 여러 객체가 공동 소유해야하는가? => std::shared_ptr

std::unique_ptrraw poinetr 대비해서 오버헤드가 없다. 전혀. 그러니 메모리를 할당하면 별 생각 없이 여기에 저장하면 된다.
하지만 프로그램 구조가 복잡하고 누가 이 객체를 소유하는지가 불명확하면 그 경우엔 shared_ptr 를 써야 할 수 있다. 이 경우엔 순환참조가 있을 수 있다면 다시 std::weak_ptr 를 써야하는 경우도 생긴다. (GC가 없으니 순환참조는 해제할 수 없다)
여하튼 이런 원칙으로 프로그램을 작성하면, 기본 생성되는 멤버함수 지울 때 정도 말고는 delete 쓸 일이 없다.

덤: 스마트 포인터의 성능이 중요한 경우 + shared_ptr 를 써야하는 구조라면 선택지는 두 가지:

  • weak_ptr 도 없어도 된다면 boost::intrusive_ptr
  • 그게 아니라면 make_shared 를 적극 활용하시라. 대부분의 구현체에서 객체에 할당한 메모리와 참조 카운터에 할당한 메모리가 인접해 있어서 cache friendly 하니 어지간한 케이스는 괜찮다.

MS Visual C++ 에서 함수에 임시 객체를 (non const) reference로 넘기기

작년 하반기에 VisualStudio 2013 기준으로 작성된 C++ 프로젝트를 linux (+ GCC)로 옮기는 작업을 했다.
그 때 제일 충격적이었던 것은 — 한 2년넘게 Windows 환경 작업을 (직업적으로는) 안한 탓이 크겠지만 — 이 글 제목의 그것:

void func(X &x);
// ...

func(X());

이런 코드가 문제없이 컴파일되고 동작하는 것.

문제는 이 코드는 GCC 혹은 clang 에서 제대로 빌드되지 않는다; 게다가 저게 단순히 저런 함수 꼴 하나였으면 참 좋았을텐데 (…), template + perfect forwarding이 섞인 코드라 반쯤 돌아버리는 줄 알았음.
여하튼 VisualStudio에서만 작업할게 아니라 다른 OS/Platform으로 포팅할 생각이 있다면 VS의 language-extension으로 지정된 부분들은 좀 버리고 갑시다.