바인더 IPC

바인더는 서로 다른 프로세스에 위치한 자원이나 코드들을 서로 연결해주는 역할을 수행한다고 했다. 좀 더 구체적으로는 다른 프로세스에 존재하는 함수를 실행할 수 있도록 해준다. 데이터의 연결은 제공하지 않는다. 함수 호출을 통해 간접적으로 데이터를 교환할 수는 있어도 바인더가 두 프로세스 사이의 데이터 공유를 직접적으로 지원하지는 않는다. 바인더가 API 수준에서 보여지고자 하는 모습은 RPC(Remote Procedure Call) 또는 RMI(Remote Method Invocation) 를 제공하는 기반구조다. RPC 하면 썬(Sun)의 RPC를, RMI하면 자바(Java)의 RMI를 떠올릴 사람들이 있을 줄 안다. 하지만 여기서는 보통명사로 사용된 용어일 뿐, 언급한 특정 시스템들을 가리키는 것이 아니니 오해 없기 바란다. (그들과 유사한 부분이 있을 거라는 생각은 해롭지 않다.) 바인더는 프로세스 A에서 프로세스 B에 있는 함수나 메써드를 호출할 수 있도록 할 뿐만 아니라, 그 과정을 최대한 자연스럽게 만드는 것을 목표로 하고 있다. "최대한 자연스럽다" 는 것은, 같은 프로세스에 있는 함수를 호출할 때와 구별하기 힘들다는 뜻이다. 이를 가리켜 좀 더 기술적인 용어인 "투명성(Transparency)" 을 사용하는 사람들도 있다. 이 "자연스러움"의 기준 중 가장 중요한 요소는 쓰레드(Thread)다. 자세히 살펴보자. 먼저 다른 프로세스들간의 함수 호출을 원격 호출(Remote Call), 동일한 프로세스 내에서의 함수 호출을 지역 호출(Local Call)이라고 부르기로 한다. 지역 호출에서 함수 F 를 호출하면 호출한(Caller) 코드가 실행되는(Callee) 쓰레드와 호출 당한 코드가 실행되는 쓰레드는 동일하다. 하지만 원격 호출에서 이는 불가능하다. 두 개의 프로세스로 나뉘어진다는 뜻은 서로 다른 쓰레드에서 실행되어야 함을 의미하기 때문이다. 하지만 호출자의 입장에서 볼 때 이 차이는 큰 문제를 일으키지 않는다. 호출 당하는 코드가 어떤 쓰레드에서 실행되는지에 관계 없이 최초의 쓰레드로 제어가 돌아오는 한 차이점을 관찰할 수 없기 때문이다. 때문에 바인더에서는 "쓰레드 이동(Thread Migration)" 이라는 개념 모델을 사용한다. 호출자가 자신의 쓰레드에서 원격 호출하면, 호출당하는 프로세스의 쓰레드로 이동하여 코드를 실행한 후, 다시 호출자의 쓰레드로 돌아오는 것이다. 아주 단순하고 당연해 보이지만 실제 구현에 있어서는 몇 가지 이슈들을 수반한다. 호출(Call), 귀환(Return), 재귀(Recursion)의 문제가 그것이다. 호출(Call)의 문제라 함은 호출 당하는 프로세스의 어떤 쓰레드로 이동할 것인가 하는 문제를 뜻한다. 호출 당하는 프로세스의 쓰레드는 어떤 방법으로 호출자가 요청한 코드를 실행하도록 준비될 수 있을까? 바인더는 이 문제를 피 호출자의 능동적인 준비를 통해 해결한다. 바인더는 피 호출자가 호출자의 요청에 즉각 응답할 수 있는 쓰레드 풀(Thread Pool)을 유지하도록 요구한다. 즉 호출자의 요청을 위해 대기하는 여러 개의 쓰레드를 미리 준비하고, 요청이 들어오면 실행 중이 아닌 쓰레드가 요청한 코드의 실행에 동원되는 것이다. 극단적인 경우 이 쓰레드 풀은 단지 하나의 쓰레드로만 구성되어도 상관없으나, 안드로이드의 ANR(Application Not Responding) 대화상자(Dialog)로 이어지는 지연을 발생시키지 않기 위해서는 충분한 크기의 쓰레드 풀이 유지되어야 한다. 귀환(Return)의 문제는 함수 실행이 끝난 후에 출발했던 쓰레드로 돌아오는 방법에 관한 문제다. 이 문제는 그다지 어려울 것이 없다. 피호출자가 실행의 종료를 알려줄 때까지 호출자가 대기할 수 있기만 하면 되기 때문이다. 즉 대기(wait)가 가능한 프로세스간의 통신 방법만 제공되면 된다는 뜻이다. 재귀(Recursion)는 셋 중 가장 복잡한 문제다. 호출당한 코드가 실행되는 도중 호출자의 코드를 원격 호출한다면 어떻게 되어야 할까? 예를 들어 프로세스 A가 프로세스 B 의 함수 f 을 원격 호출한다. 이 때 함수 f 의 인자로 콜백(Callback) 함수 g 를 함께 넘겼다. 함수 f 가 g 를 호출한다면 어떻게 되어야 할까? 또 어떻게 되는 것이 가장 자연스러울까? 지역 호출의 경우와 비교해 본다면 쉽다. 지역 호출의 경우 처음부터 끝까지 동일한 쓰레드에서 실행된다. 때문에 호출자가 원격 호출로 인한 콜백을 지역 호출의 경우와 구분하지 못하도록 만들기 위해서는, 콜백 g가 함수 f 를 호출이 시작된 쓰레드이자 함수 f의 반환을 기다리고 있는 쓰레드에서 실행되어야 한다. 이러한 재귀적인 쓰레드 이동을 올바로 처리하기 위해서는 호출 스택(Call Stack)을 관리하는 방법이 필요하다. 바인더는 이러한 작업들을 효율적이고 안전하게 수행하기 위해 특별한 프로세스간 통신 기작(mechanism)을 도입한다. 프로세스들간의 통신을 가리켜 IPC(Inter-Process Communication) 라고 하는데, 리눅스는 소켓(Socket) 을 비롯하여 SysV 또는 POSIX 공유 메모리(Shared Memory)등 여러 가지 IPC 를 제공하고 있다. 하지만 안드로이드에서는 SysV 와 POSIX 공유 메모리를 모두 지원하지 않는다. 대신 Ashmem 이라는 것을 지원하는데, 이름없는 공유 메모리(Anonymous Shared Memory) 라는 것으로, 기존의 공유 메모리에서 발생할 수 있는 시스템 누수를 방지하기 위해 도입되었다. 하지만 이 역시 바인더에서는 사용되지 않는다. 비록 바인더와 함께 사용될 수는 있지만 바인더의 기초를 이루고 있지는 않다. 바인더가 사용하는 IPC는 /dev/binder 라는 바인더 전용의 장치(Device)를 통해 이루어진다. 바인더 IPC를 사용하고자 하는 프로세스는 /dev/binder 장치를 열어야(Open) 한다. 쓰레드마다 따로 열 필요는 없고, 여러 쓰레드가 FD(File Descriptor)를 공유해도 좋다. ioctl 을 사용하여 장치와 대화해야 하는데, 필요한 상수들은 NDK의 <linux/binder.h> 에 정의되어 있다. BINDER_CURRENT_PROTOCOL_VERSION 매크로는 프로토콜의 버전을 정의하는데, 이 문서는 버전 7을 설명하고 있다. ioctl 명령어와 인자의 형식을 정리해보면 다음 표와 같다.

request Data type R/W
BINDER_WRITE_READ struct binder_write_read Read & Write
BINDER_SET_IDLE_TIMEOUT int64_t Write Only
BINDER_SET_MAX_THREADS size_t Write Only
BINDER_SET_IDLE_PRIORITY int Write Only
BINDER_SET_CONTEXT_MGR int Write Only
BINDER_THREAD_EXIT int Write Only
BINDER_VERSION struct binder_version Read Only

  BINDER_WRITE_READ 명령을 통해 다른 프로세스와 통신할 수 있는데, struct binder_write_read 구조체로 데이터 송신과 수신을 한번에 요청할 수 있다.

struct binder_write_read {
    signed long write_size;
    signed long write_consumed;
    unsigned long write_buffer;
    signed long read_size;
    signed long read_consumed;
    unsigned long read_buffer;
};

  write_buffer 와 read_buffer 의 크기는 사용자가 각각 write_size 와 read_size 에 저장한다. write_consumed 는 보통 0으로 설정되는데, 명령 실행 시에 드라이버(Driver) 에 의해 소비된 write_buffer 의 크기가 기록된다. 마찬 가지로 드라이버에 의해 read_buffer 에 채워진 데이터의 크기가 read_consumed 에 저장된다. BINDER_WRITE_READ 명령은 write_buffer 와 read_buffer 로 임의의 바이트스트림(bytestream)을 전달하는 것이 아니라, 일정한 형식을 갖춘 데이터스트림(data stream)을 주고 받는다. 데이터스트림은 int 형식의 커맨드(Command) 와 그 뒤를 따르는 가변 길이 데이터의 반복이다. write_buffer 로 보낼 수 있는 커맨드는 enum BinderDriverCommandProtocol 에 의해 정의되는데, 각 값들과 대응하는 데이터의 형식을 정리하면 다음 표와 같다.

Command Data type
BC_TRANSACTION struct binder_transaction_data
BC_REPLY struct binder_transaction_data
BC_ACQUIRE_RESULT int
BC_FREE_BUFFER int
BC_INCREFS int
BC_ACQUIRE int
BC_RELEASE int
BC_DECREFS int
BC_INCREFS_DONE struct binder_ptr_cookie
BC_ACQUIRE_DONE struct binder_ptr_cookie
BC_ATTEMPT_ACQUIRE struct binder_pri_desc
BC_REGISTER_LOOPER None
BC_ENTER_LOOPER None
BC_EXIT_LOOPER None
BC_REQUEST_DEATH_NOTIFICATION struct binder_ptr_cookie
BC_CLEAR_DEATH_NOTIFICATION struct binder_ptr_cookie
BC_DEAD_BINDER_DONE void*

  마찬가지로 read_buffer 를 통해 수신되는 커맨드는 enum BinderDriverReturnProtocol 에 의해 정의되는데, 각 값들과 대응하는 데이터의 형식을 정리하면 다음 표와 같다.

Command Data type
BR_ERROR int
BR_OK None
BR_TRANSACTION struct binder_transaction_data
BR_REPLY struct binder_transaction_data
BR_ACQUIRE_RESULT int
BR_DEAD_REPLY None
BR_TRANSACTION_COMPLETE None
BR_INCREFS struct binder_ptr_cookie
BR_ACQUIRE struct binder_ptr_cookie
BR_RELEASE struct binder_ptr_cookie
BR_DECREFS struct binder_ptr_cookie
BR_ATTEMPT_ACQUIRE struct binder_pri_ptr_cookie
BR_NOOP None
BR_SPAWN_LOOPER None
BR_FINISHED None
BR_DEAD_BINDER void*
BR_CLEAR_DEATH_NOTIFICATION_DONE void*
BR_FAILED_REPLY None

  원격 호출에 사용되는 커맨드는 BC_TRANSACTION, BR_REPLY 다. 두 커맨드가 공통으로 갖는 데이터 형식은 struct binder_transaction_data 으로 다음과 같이 선언되어 있다.

struct binder_transaction_data {
    union {
        size_t handle;
        void *ptr;
    } target;
    void *cookie;
    unsigned int code;
    unsigned int flags;
    pid_t sender_pid;
    uid_t sender_euid;
    size_t data_size;
    size_t offsets_size;
    union {
        struct {
            const void *buffer;
            const void *offsets;
        } ptr;
        uint8_t buf[8];
    } data;
};

  원격 호출을 하기 위해서는 원격 함수의 주소가 필요하다. 원격 함수는 (target.handle, code) 를 통해 선택된다. 바인더에서는 관리의 단위가 함수가 아니라 인터페이스(Interface)다. 이는 자바(Java) 의 인터페이스나 C++ 의 추상 클래스(Abstract Class)로 대응되는 개념인데, 한 무더기의 함수 묶음이라고 생각하면 된다. 이 인터페이스를 이루는 각 함수는 1부터 시작하는 일련번호가 부여되는데 이 값이 code 다. code 에 부여되는 값에 대해서는 호출하는 쪽과 호출당하는 쪽 간에 사전 합의가 있어야 한다. 한편 각 인터페이스는 별도의 과정을 통해 등록되고 역시 일련번호가 부여된다. 이 일련번호는 code와 달리 사전 합의가 아니라 동적인 협상(Negotiation)을 통해 확정되게 된다. 이 값을 target.handle 에 저장한 후 BC_TRANSACTION 커맨드에 실어 보내면 원격 함수가 실행되게 된 후, BR_REPLY 커맨드로 응답이 온다. 응답이 올 때까지 ioctl 은 대기 상태로 들어간다. 한편 호출 당하는 쪽에서 저절로 해당 함수가 실행될 리는 없다. 호출 당하는 쪽에서는 미리 쓰레드 풀을 만들어 ioctl 호출을 해야 한다. 이 때도 역시 BINDER_WRITE_READ 명령을 사용하는데, write_size 를 0으로 설정한다. 상대방이 BC_TRANSACTION 을 송신할 때 까지 대기 상태로 있다가, 드라이버에 의해 BR_TRACSACTION 으로 변환된 커맨드가 쓰레드 중 하나로 전달되게 된다. 이 쓰레드는 target 과 code 를 통해 요청한 함수를 구분할 수 있다. 이제 이 함수를 실행한 후에 그 결과를 BC_REPLY 로 알리면 드라이버에 의해 BR_REPLY 로 변경되어 호출자의 read_buffer 로 전달되게 된다. 지금까지 원격 호출이 이루어지는 과정을 세부 사항들을 상당히 생략한 채로 살펴보았다. 세부 사항들은 앞으로 계속될 글에서 좀 더 명확해질 것이다. 그러면 앞에서 언급했던 세가지 이슈가 어떻게 처리되고 있는지 다시 한번 정리해 보자. 호출의 문제는 거의 전적으로 드라이버가 책임지고 있다. 즉 호출자가 지정한 target.handle 이 어떤 프로세스에 해당하는 지 판단하고, 해당 프로세스의 쓰레드 하나를 선택하여 배달(Dispatch)하는 과정은 드라이버 내부에서 일어나고 있다. 귀환의 문제 역시 드라이버가 책임지고 있다. 응답이 올 때까지 ioctl은 대기 상태에 들어가며, 호출자를 기억했다가 피호출자의 응답을 되돌려주는 책임 역시 드라이버가 지고 있다. 마지막으로 재귀의 문제 역시 전적으로 드라이버의 책임이다. 앞에서 살펴본 과정에서는 드러나지 않았지만, 만약 피 호출자가 호출자의 함수를 원격 호출한다면 BC_REPLY 하기 전에 BC_TRANSACTION 을 보내게 된다. 이는 드라이버에 의해 유지되는 호출 스택(Call Stack)에 의해 재귀적 호출임이 감지되고, 호출자의 쓰레드로 보내진다. 응답이 올 때 까지 대기 중이던 호출자의 ioctl 은 BR_REPLY 대신 BR_TRANSACTION 으로 복귀한다. 호출자는 해당하는 콜백을 실행한 후에 BC_REPLY 를 보낸다. 이제 다시 호출자는 BR_REPLY 가 올 때까지 ioctl 로 대기하는 상태를 유지하게 된다. 따라서 바인더가 도입한 새 IPC 는 원격 호출이라는 목적에 특화된 기능들을 제공하고 있고, 비교적 간단한 설계를 유지하면서 효율적인 구현을 가능하게 해주고 있다.   하지만 함수는 입력과 출력이 있어야 한다. 인자와 반환 값은 어떻게 주고 받는 것일까?