2017년 5월 10일 수요일

Module based multi-thread messaging

멀티쓰레드 상태에서 데이터 동기화를 완벽히 처리하려면 아래 두 가지 규칙만 제대로 처리하면 된다.
  1. 변경 금지: 특정 트랜젝션 내에서 연관성을 가진 일련의 데이터를 참조하는 동안 다른 쓰레드가 그 값을 변경할 수 없도록 한다.
  2. 참조 금지: 특정 트랜젝션 내에서 연관성을 가진 일련의 데이터를 변경하는 도중에 다른 쓰레드가 그 값을 참조할 수 없도록 한다.

매우 단순해 보이지만, 위 규칙을 적재적소에 정확히 적용하는 것은 매우 까다로운 작업이며, 코드만 조금 변경하여도 그 안전성은 너무나 쉽게 깨지고 만다.

지난 글에 아래와 같이 모든 객체를 모듈 단위로 그룹화하여 락킹을 자동 처리함으로써, 데이터 동기화 문제를 아예 언어 차원에서 해결하는 아이디어를 소개한 바 있다.
 1) 메모리 영역을 다수의 모듈로 구분한다.
 2) 모든 객체는 반드시 하나의 모듈에 속하고, 소속 모듈을 변경할 수 없다.
 3) 특정 객체를 참조하기 직전에 해당 객체가 속한 모듈을 자동 락킹하고, 트랜젝션 종료 후 락을 해제한다. 즉 해당 모듈에 속한 모든 객체를 최초 참조 시부터 트랜젝션 종료 시까지 다른 쓰레드가 변경 또는 참조하지 못하도록 한다.

위 방식을 통해 데이터 동기화 오류를 해결할 수는 있으나, 동기화 단위가 객체가 아닌 객체의 그룹 즉, 모듈 단위로 매우 커지다 보니 쓰레드 데드락 발생 확률이 매우 커지는 치명적 단점이 있다.
쓰레드 데드락은 여러 쓰레드가 다수의 객체를 엇갈린 순서로 순차적으로 락킹할 때 발생한다. 쓰레드 데드락 발생을 근본적으로 해결하려면 순차적 락킹 대신에 아래와 같이 동시적(!) 락킹을 사용해야만 한다.
  1. 트랜젝션을 수행하기 직전에
  2. 트랜젝션 수행 도중 참조 가능한 모든 객체의 락킹을 시도한다.
  3. 참조 가능한 객체 중 하나라도 즉시 락킹할 수 없는 경우, 하나라도 try_lock 이 실패한 경우 이미 락킹한 모든 객체의 락을 해제한 후 과정 2)부터 되풀이한다.
  4. 트랜젝션 종료 후, 모든 객체의 락을 해제한다.

"함수 수행 도중 참조 가능한 모든 객체"를 찾아서 일괄적으로 락킹을 한다는 아이디어는 매우 바보같아 보인다. 그러나, 객체가 아닌 모듈을 통째로 락킹하는 방식을 사용하는 경우엔 아래와 같이 매우 쉽고 단순하게 구현할 수 있다.
  1. 트랜젝션을 시작하기 직전에, 즉 그 시작 함수를 호출하기 직전에
  2. 해당 함수 인자 중 객체형 인자들이 속한 모듈들을 모두 락킹한다. (this 포함)
  3. 함수 수행 시, 미리 동기화되지 않은 외부 모듈의 객체에 대해선 비동기 메시징 호출만 허용한다.
  4. 해당 함수 종료 후 모듈의 락킹을 해제한다.

즉, 특정 트랜젝션 수행 직전에 그 시작 함수의 인자 개수 정도의 모듈들만을 락킹함으로써 동기화 문제 및 쓰레드 데드 락 문제를 매우 간단히 해결할 수 있다.

위 방식의 단점은 안전성 확보를 위해 동기화 필요성이 없는 객체들까지 모두 포괄적으로 동기화한다는 것이다. 이 때문에 기존의 정교한 수작업 방식에 비해서 퍼포먼스 효율성이 크게 떨어진다. 특히 빈번하게 여러 모듈을 동시에 동기화(=락킹)하는 경우, 해당 모듈들은 멀티쓰레드 코딩의 주목적인 퍼포먼스를 얻어낼 수가 없다.
그럼에도 불구하고, 최악의 상황에서도 단일 쓰레드 코딩에 비해서 퍼포먼스가 더 나빠지지는 않으며, 적절한 최적화를 거치면 퍼포먼스를 충분히 올릴 수 있다 (빈번한 Lock/Unlock 과정의 오버헤드때문에 단일 쓰레드 코드보다 더 느려질거라 예상할 수도 있으나, 불필요한 Lock/Unlock 동작을 회피하는 최적화는 그리 어렵지 않다.)

모듈을 적절한 규모로 잘 설계하고, 모듈 간 인자 전달 시 최대한 불변형 객체를 사용하거나 객체를 복사(deep-copy)하여 전달하는 방식을 사용하여 각 모듈이 별개의 쓰레드에서 최대한 독립적으로 실행될 수 있도록 최적화함으로써 멀티코어를 최대한 활용할 수 있다.

이러한 안전한(!) 최적화만으로 불충분한 경우, 즉 병렬 컴퓨팅 기법등 좀 더 정교한 최적화를 위해서 특별히 thread_unsafe 함수를 예외적으로 허용할 수 있다. 위험성이 명시되지 않은  모든 코드에 대해서 항상 쓰레드 안전성이 보장된다면, 수작업을 통한 국소적인 최적화 작업 또한 그 위험성을 대폭 줄이고 효율화할 수 있을 것이다. 모듈단위 동기화 기법은 객체가 아닌 대규모 데이터 영역을 다른 쓰레드로 부터 쉽게 배타적으로 보호할 수 있기 때문 병렬처리를 안전하게 처리하는 데에도 큰 도움이 될 것이다.

모듈 단위 동기화 방식을 기존의 언어를 이용하여 구현하는 것은 가능할 수도 있으나, 불행하게도 전혀 실용적 가치가 없다. 위험성이 명시되지 않은 일반 코드의 쓰레드 안전성을 보장한다는 명제 자체가 성립될 수 없기 때문이다.
이에, 만약 모듈 단위 동기화 방식으로 쓰레드 안정성을 보장하는 새로운 언어가 있다면 어떤 실용적 가치가 있을가 생각해 보았다.
첫 번째는 멀티쓰레드 기반 비동기 메시징/콜백 처리일 것이다. 기존의 비동기 메시징 또는 콜백은 단일 쓰레드 기반이거나, C++ async 처럼 쓰레드 안전성을 전혀 보장하지 않는 형태로만 제공된다. 이에 반해 새로운 언어는 쓰레드 안전성이 보장되는 비동기 메시징을 매우 쉽게 제공할 수 있다.
두 번째는 대용량 STM(Software Transactional Memory Model) 구현일 것 같다. 특정 트랜젝션을 시작하기 전에 모듈 전체 메모리를 copy-on-write 방식으로 복사하고, 트랜젝션 실패 시 그 이전 메모리로 복구하는 방식을 취하면, 매우 간단하고 효율적으로 대용량 STM 을 구현할 수 있을 것으로 예상된다. 또한 모듈 단위 방식은 STM을 일부 트랜젝션에만 부분적으로 적용하는 것이 용이하다는 것도 큰 장점이 될 것이다.

이 중 멀티쓰레드 비동기 메시징이 가능한 언어는 어떤 형태가 될 지 상상력을 발휘해  보았다. 자바 언어를 변형하여 모듈 단위 동기화 제어를 위한 몇 가지 문법을 덧붙였다.
먼저 모듈 구분을 위해 기존의 객체 레퍼런스를 아래와 같이 세분화해보았다.

객체 레퍼런스 유형

  1. Domestic reference (syntax: Foo foo)
    동일한 모듈에 속한 객체의 레퍼런스. 일반 OOP 언어 레퍼런스와 동일하다. 동일 모듈 내에서는 그 사용에 전혀 제약이 없다. Object.toMuttable(targetObj.getModule()) 함수를 이용하여 특정 모듈용 deep-copy 복사본을 생성할 수 있다.
  2. Immutable reference (syntax: Foo# foo)
    내부 데이터 변경이 불가능한 레퍼런스. 해당 객체뿐 아니라 해당 객체의 모든 함수 반환값 또한 불변형이다. 당연히 모듈간에 자유로이 공유할 수 있다. Object.toImmutable() 함수로 일반 객체를 deep-copy 한 불변 객체를 생성할 수 있다.
  3. Synchronized argument reference (syntax: Foo& foo)
    함수의 인자형으로만 사용된다. 해당 인자가 속한 모듈은 트랜젝션 수행 동안 락킹  상태가 유지된다. 객체의 값을 참조 및 변경할 수 있으나, 모듈 내 field 에 assign 할 수 없다. 즉, 모듈 메모리 내에 그 레퍼런스를 기록할 수 없다.
  4. Foreign reference (syntax: Foo! foo)
    외부 모듈에 속한 객체에 대한 레퍼런스. Future reference 라고도 불린다. Foreign Reference 는 동기적 API 호출이 불가능하고, 비동기 메시징만 허용된다.

참고로, 모든 객체 레퍼런스를 이용하여 API를 호출한 반환값은 해당 객체 레퍼런스 타입이 덧씌여진다. 예를 들어, Item getItem() 함수의 반환값의 레퍼런스 타입은 아래와 같이 자동 변환된다. 레퍼런스 변환은 항상 제약조건이 더 많은 타입으로만 가능하며, 그 역은 허용되지 않는다.
     Item item = foo.getItem(index);
     Item& item = ((Foo&)foo).getItem(index);
     Item# item = foo.toImmutable().getItem(index);
     Item! item = ((Foo!)foo) | getItem(index);


함수 타입도 더 세분화하여 안정성과 최적화 효율을 올릴 수 있을 것이다. 이 중 필수적인 것은 아래 몇 가지일 것 같다.

함수 유형

  1. 기본형 함수
    함수 내부에서 참조 가능한 모든 객체가 미리 락킹되어 있음이 보장된다. 즉 항상 쓰레드 안전성이 보장된다.
  2. interruptible 함수
    syntax: interruptible void connect();
    connect(), read() 등 함수 수행 도중 thread가 wait 상태로 변경될 수 있는 함수이다. 내부 동기화는 명시적으로 처리해야 하나, thread_safe 함수이다. interruptible 함수는 항상 비동기 메시징 형태로만 호출 가능하다. 즉, 다른 모든 함수는 항상 Non-blocking API임이 보장된다.
  3. atomic 함수
    syntax: atomic int incrementCounter();
    락킹없이 안전한 데이터 변경이 보장되도록 구현된 thread_unsafe 함수.
  4. parallel 함수
    syntax: parallel int getTotal(int values[]);
    특정 모듈을 여러 쓰레드가 동시에 참조 및 변경하여도 문제가 없도록 구현된 함수를 말한다. (해당 모듈이 락킹된 상태에서만 호출 가능한 비동기 함수이다.)



아래는 UI Thread 와 통신 Thread 를 별도로 생성하지 않고, 비동기 메시징 방식으로 간단히 처리하는 예제이다. 상상력으로 만들어낸 어설픈 수준의 언어로 예제까지 만든 오버액션을 너무 많이 비웃지는 마시길 바란다. ^^;;


class BlogClient {
atomic String getUserName();
interruptable void login(String# name, String# passwd) throws IOException;
interruptable Iterator<BlogPost>! getBlogPosts() throws IOException;
parallel List<BlogPost>! findBlogs(String# textToSearch) throws IOException;
}

class SimpleBlogUI extends UIView {

private BlogClient! client;
private BlogListView blogListView;

SimpleBlogUI() {
       // 새로운 모듈 공간에 객체를 생성할 때는 new 대신에  new! 를 사용한다.
      client = new! BlogClient();
      // 서버 접속 요청.
      client ! connect()
      -> try () {
System.out ! println(“Service connected”);
      }
      catch (Exception e) {  
// 비동기 메시징 오류 처리.
           System.err ! println(“Service connection failed: “ + e.getMessage());
           System.exit();
      }

      // Login Dialog 를 생성한다. 아직 서버 연결은 안된 상태이다.
      showLoginDialog();
}

// 다이얼로그에 입력된 아이디와 암호로 로그인을 요청한다.
void onLoginButtonPressed(String# name, String# passwd) {
     // 다이얼로그에 입력된 아이디와 암호로 로그인 요청 메시지를 전송한다
     // 로그인 요청은 위의 서버 접속 요청의 처리가 완료된 후에 처리된다.
     client! login(name, passwd)
     -> try () {
           // 블로그 리스트를 요청한다.
Iterator<BlogPost> posts! = client ! getBlogPosts();

for (BlogPost post : posts) try {
// for 조건문 속에서 Future reference 가 사용되거나, 비동기 메시지 호출이 있는 경우 해당 for-loop 전체가 비동기적으로 실행된다. 이를 통해 대량의 블로그를 백그라운드에서 순차적으로 읽어 오면서 UI 를 점진적으로 갱신하는 것이 가능하다. (Reactive 방식과 장단점을 비교해 주기 바란다.)
                 try {
           blogListView ! addItem(post.title, post.content);
                      ...
     }
     catch (Exception e) {
           // for-block 내부의 exception 처리.
     }
}
catch (IOException e) {
    // 비동기형 for-block 바깥에서의 exception 처리.
}
       }
       catch (IOException e) {
           onLoginFail(e);
       }
}

          /* 특정 문자열이 포함된 블로그를 병렬적으로 검색한 후 그 결과값을 받는다. */
void onFindBlogs(String# textToSearch) {
    // client !! { … }
               // client 객체가 속한 모듈을 parallel 함수 전용 모드로 락킹한다.
   // parallel 함수 전용 모드에선 parallel 함수만 호출 가능하다.
   // 그 외의 함수는 병렬처리 안전성을 보장하지 않는 것으로 간주하기 때문이다.
   // 참고로 락킹은 별도 쓰레드에 의해 비동기적으로 이루어진다.
              // 즉 본 함수는 락킹 요청만 전송하고, 즉시 종료된다.
   client !! {
for (int i = 0; i < client.getFindThreadCount(); i ++) {
     client.findBlogs(textToSearch, i)
     -> (posts) {
           for (BlogPost post : posts) {
                 findResultView ! addResult(posts);
           }
      }
}
               }
               finally {
                     // parallel 함수 전용 모드 종료 후, 검색이 종료되었음을 알린다.
                     
findResultView ! findFinished();
               }
}
}

참고로, 비동기 메시지의 결과를 처리하는 콜백 함수 또는 블럭은 -모듈 동기화를 최적화하기 위하여- lambda 형과 closure 형을 구분하여 쓸 수 있는 방법이 더 필요하나, 이번 글의 범위에는 포함하지 않았다.

새로운 언어는 멀티쓰레드 환경에서 의도적 예외상황을 제외하고 다음 3 가지를 보장한다.
  1. Data synchronization
  2. No thread deadlock
  3. No thread blocking except interruptible functions.

과연 이 글의 주장, 즉 Thread-safety 를 보장하는 새로운 OOP 언어의 탄생이 논리적으로 가능할 지, 실용성이 있을지 함께 검토하고 의견 나눠주는 분들이 있기를 기대한다.