gimmesilver's blog

Agbird.egloos.com

포토로그



하스켈을 이용한 병렬 프로그래밍...2 하스켈 스프링노트

 하스켈은 STM 동기화를 위한 별도의 모나드 및 메모리 저장 타입이 존재한다는 특징이 있습니다. 하스켈 STM을 위한 메모리 타입으로는 TVar, TArray, TChan, TMVar가 있습니다. 각각 IORef, Array, Chan, MVar에 해당하는 트랜잭션 메모리입니다.

 참고로 IORef, Array, Chan, MVar 는 하스켈에서 데이터를 저장하기 위해 사용되는 데이터 타입입니다. 각각에 대해 간단히 언급하자면 IORef는 일반적인 데이터를 저장하는 용도로 사용하는 변수이며, Array는 말그대로 고정 길이 배열, Chan은 스트림 방식(FIFO)의 데이터 구조에 해당합니다. MVar는 이전에 썼던 하스켈 네트워크 프로그래밍 3 에서 설명한 적이 있는 자체적으로 동기화 기능이 제공되는 변수입니다. 
 
 혹시라도 '어라? 순수 함수형 언어인 하스켈은 데이터를 저장하지 않는 것으로 알고 있는데?' 라고 생각하는 분이 계실지 모르지만 하스켈도 데이터 저장이 가능합니다. 다만 하스켈의 데이터 저장방식은 명령형 언어와 다소 다릅니다. 간단히 언급하자면 모나드라고 하는, 일종의 박스 개념의 함수를 계속 전달하는 방식으로 데이터 저장 기능을 구현합니다. 이것에 대해서는 다음에 자세한 글을 쓰도록 하겠습니다. 어쨌든...

 만약 STM을 이용해 동기화시킬 자원이 있다면 TVar, TArray, TChan, TMVar 중 적절한 타입에 데이터를 읽고 씁니다. 예를 들어 이전 글에서 언급한 계좌 이체를 간단하게 구현하면 아래와 같습니다.

import Control.Concurrent.STM

type Account = TVar Int

withdraw :: TVar a -> a -> STM ()
withdraw account amount = do
    balance <- readTVar account
    writeTVar account (balance - amount)

deposit :: TVar a -> a -> STM ()
deposit account amount = withdraw account (-amount)

transfer :: TVar a -> TVar a -> a -> STM ()
transfer from to amount = withdraw from amount >> deposit to amount

transferTest :: IO ()
transferTest = do
    from <- newTVarIO 1000
    to <- newTVarIO 500
    putStrLn "before transfer: "
    print =<< atomically (readTVar from)
    print =<< atomically (readTVar to)
    atomically (transfer from to 100)
    putStrLn "after transfer: "
    print =<< atomically (readTVar from)
    print =<< atomically (readTVar to)

 위에서 계좌의 잔액을 관리하는 Account라는 데이터 타입을 트랜잭션 메모리 중 하나인 TVar 타입으로 정의했습니다. TVar 변수를 생성하는 함수는 newTVar와 newTVarIO 이며(이 둘의 차이는 조금 있다가 설명하겠습니다) TVar에 저장된 값을 읽고 쓰는 함수는 각각 readTVar와 writeTVar 입니다.

 계좌 이체를 위해서는 입금과 출금 기능이 필요한데 위에서 deposit과 withdraw가 각각 이 기능을 수행합니다. 그리고 이체 함수인 transfer는 from 계좌에서 지정한 금액을 출금하여 to 계좌에 입금하는 기능을 수행합니다. transferTest 함수는 이체 기능을 테스트하는 함수입니다. 각각 1000원과 500원이 들어 있는 계좌를 생성하고 100원을 이체한 결과를 출력합니다.
 믿을 수 없겠지만 위 이체 함수는 완벽하게 동기화 되어있습니다. 그리고 이 동기화의 핵심 기능인 트랜잭션 처리는 atomically 라는 함수가 수행합니다. atomically 함수는 트랜잭션 메모리를 다루는 함수를 입력값으로 받아 트랜잭션 처리를 수행합니다. 

 newTVar, readTVar, writeTVar와 같은 트랜잭션 메모리를 처리하는 함수들은 모나드 함수입니다. 하스켈의 일반 함수들과 달리 이런 함수들은 변수를 다루기 때문에 수행 순서에 영향을 받습니다. 그래서 이런 트랜잭션 메모리를 다루는 함수는 do 표기법이나 >> 같은 모나드 함수로 순서를 지정합니다. 그리고 IO 모나드 함수처럼 트랜잭션 메모리를 다루는 함수들도 반환값을 모나드 타입에 감싸서 반환합니다. 

 IO 모나드 함수들의 모나드 타입은 알다시피 IO 타입입니다. 반면 STM 처리 함수들의 모나드 타입은 STM 입니다. 위의 withdraw, deposit, transfer 함수의 반환 타입이 STM () 이라고 되어 있는 이유는 이처럼 위 함수들이 트랜잭션 메모리를 처리하는 모나드 함수이면서 특별히 반환값을 가지지 않기 때문입니다.
 
 아는 사람은 다 알고 모르는 사람은 다 모르는 하스켈의 특징 중 하나는 강력한 정적 타입 시스템입니다. 하스켈 함수는 - 컴파일러의 추론에 의해서든 프로그래머의 지정에 의해서든 - 한번 타입이 지정되면 그 타입에 맞는 값만을 처리합니다. 그런데 위의 withdraw, deposit, transfer 함수의 타입은 STM () 입니다. 반면 transferTest의 타입은 IO () 입니다. 즉, transferTest는 IO 모나드 함수이기 때문에 STM 모나드 함수인 withdraw, deposit, transfer 함수는 내부에서 처리할 수 없습니다.

 이 둘 간을 연결시켜주는 함수가 바로 atomically 함수입니다. 앞서 언급한대로 atomically함수는 트랜잭션 처리를 수행할 뿐만 아니라 STM 모나드 타입을 받아서 IO 모나드 타입으로 변환하기 때문에 STM 모나드 함수들을 IO 모나드 함수내에서 사용하려면 반드시 이 atomically 함수의 도움을 받아야 합니다. 그리고 바로 이 점이 하스켈 STM의 막강한 점입니다!

'공유할 대상을 명확히 표시해라. 그러면 실수하지 않을 것이다!'

 하스켈에서 병렬 프로그래밍 시 쓰레드 메인 흐름은 IO 모나드 함수내에서 이루어집니다. 그리고 쓰레드간의 공유가 필요한 자원을 처리할 때는 STM 모나드 함수 내에서 이루어집니다. STM 모나드 함수 자체는 트랜잭션 처리를 하지 않습니다. 다만 트랜잭션 처리를 할 수 있는 재료일 뿐입니다. 따라서 실제 트랜잭션 처리를 하려면 atomically 함수로 명시해야 합니다. 
 
 이것은 락 기반 동기화 기법에서 락 동기화를 위해 락의 획득과 해제를 명시적으로 선언하는 것과 유사합니다. 하지만 락 기반 동기화는 언제 어디에서 락을 획득하고 해제해야 하는지를 판단하기 쉽지 않을 뿐더러 잘못 지정했을 경우 데드락과 같은 대략 난감한 상황를 초래합니다.

 반면 하스켈 STM에서는 동기화가 필요한 부분에서 atomically함수를 호출해주지 않으면 타입이 맞지 않아 컴파일 에러가 발생합니다. 반대로 말하면 컴파일 에러가 발생하지 않으면 atomically를 호출할 필요가 없다는 뜻이기도 합니다. 결국 프로그래머는 동기화를 어느 부분에서 수행해야 할지를 타입만 보고도 명확하게 인지할 수 있으며 혹여 실수한 부분이 있더라도 컴파일러(혹은 인터프리터)가 친절하게 알려줍니다. 

'대상과 행위를 분리해라, 그러면 프로그래밍이 간단해 질것이다!'

 위의 계좌 이체 프로그램의 배치 버전은 아래와 같습니다.

raw account amount = do
    balance <- readIORef account
    writeIORef account (balance - amount)

deposit account amount = withdraw account (-amount)

transfer from to amount = withdraw from amount >> deposit to amount

test = do
    from <- newIORef 1000
    to <- newIORef 500
    transfer from to 200

 놀랍게도 병렬 프로그래밍 버전과 비교했을때 빨간색으로 강조된 부분을 제외하고는 달라진 부분이 전혀 없습니다.(심지어 달라진 부분도 동일한 기능에 대한 IO 버전과 STM 버전 함수라는 차이일 뿐입니다.) 이것은 반대로 하스켈 STM을 이용하면 배치 프로그램을 병렬 프로그램 버전으로 업그레이드하기가 매우 쉽다는 것을 의미합니다.

 이것이 가능한 이유는 하스켈 STM이 동기화를 위해 동기화 대상이 되는 부분과 실제 동기화를 수행하는 부분을 명확하게 분리해서 표현하도록 설계되었기 때문입니다. STM 모나드 함수는 실제 동기화를 수행하지 않습니다. 다만 동기화 대상이 되는 공유 자원을 STM 모나드 타입으로 정의할 뿐입니다. 실제 동기화를 수행하는 것은 atomically입니다. 때문에 언제 atomically를 호출하느냐에 따라 실제 동기화가 결정됩니다. 

 이런 특징 때문에 여러 개의 STM 모나드 함수들을 조합하는데 유연합니다. 락 기반 동기화를 사용하면 여러 개의 락 동기화 함수들을 묶어서 하나로 처리하기가 까다롭습니다. 예를 들어,

void foo() {
    lock();
    do somthing...
    unlock();
}

void bar() {
    lock();
    do something...
    unlock();
}

 이런 함수가 있을 때 위 두 함수를 내부에서 순서대로 호출하면서 어떤 작업을 하는 foo_bar()라는 함수를 동기화 하려면 어려운 문제가 있습니다.

void foo_bar() {
    lock();
    foo();
    bar();
    unlock();
}

 이렇게 하게 되면 foo_bar()가 락을 획득한 상태에서 foo() 함수 내부에서도 락을 획득하려고 하기 때문에 데드락이 발생합니다.(자바의 경우 락 카운팅 기능이 있어서 동일한 쓰레드에서 여러 번 락을 획득해도 되기 때문에 이런 문제가 발생하지 않습니다만 C나 C++의 경우 이런 문제가 발생합니다.)

 따라서 이런 경우 foo(), bar() 함수 내부에 있는 lock(), unlock() 함수는 외부로 빼줘야 하는데 이 경우에는 foo(), bar() 함수 호출 시 매번 lock(), unlock() 함수를 호출해줘야 하며 자짓 이것을 빠뜨릴 경우 문제가 발생할 수 있습니다.

 하지만 함스켈 STM의 경우는 위의 transfer 함수처럼 STM 모나드 함수들을 쉽게 연결할 수 있으며 연결된 전체 부분을 atomically로 묶어주기만 하면 안전하게 처리됩니다. 결국 하스켈 STM은 동기화 대상과 실제 동기화 처리가 완벽하게 분리되어 있기 때문에 배치 프로그램과 병렬 프로그램의 로직에 차이가 크지 않으며 마치 배치 프로그램을 작성하듯 쉽게 프로그래밍이 가능합니다. 이것은 하스켈 정적 타입 시스템을 통한 동기화 로직 체크 기능과 더불어 병렬 프로그래밍을 안전하면서도 쉽게 할수 있도록 도와주는 중요 특성이 됩니다.

 참고로 만약 매번 atomically를 호출하는 것이 번거롭다고 생각하면 자주 호출되는 STM 모나드 함수를 atomically와 합성한 새로운 함수를 정의해주면 됩니다. 위 소스에서 newTVarIO가 바로 그런 함수입니다. newTVarIO는 아래와 같이 정의된 함수입니다.

newTVarIO = atomically . newTVar

 마찬가지로 readTVar, transfer도 합성을 통해 쉽게 자동 동기화 함수를 만들 수 있습니다.

readTVarIO = atomically . readTVar
transferIO = atomically . transfer

그러면 transferTest는 아래와 같이 수정가능할 것입니다.

transferTest = do
    from <- newTVarIO 1000
    to <- newTVarIO 500
    putStrLn "before transfer: "
    print =<< readTVarIO from
    print =<< readTVarIO to
    transferIO from to 100
    putStrLn "after transfer: "
    print =<< readTVarIO from
    print =<< readTVarIO to

'모 아니면 도다! 개,걸,윷 이딴거는 없는거다!'

 마지막으로 하스켈 STM은 트랜잭션 처리가 되어 있기 때문에 예외에 매우 안정적입니다. 왜냐하면 atomically 함수는 다른 쓰레드가 방해할 때 뿐만 아니라 트랜잭션 수행 도중 예외가 발생했을 때에도 지금까지 수행한 작업들을 모두 취소해버리기 때문입니다.

 락 기반 동기화의 경우 이런 예외를 처리하기가 까다롭습니다. 계좌 이체를 가지고 계속 예를 들어보자면,

 계좌 이체 시 from 에서 출금을 하고나서 to 에 입금 도중 문제가 발생하면 전체 이체 작업이 실패한 것이므로 원래 상태로 돌아가야 합니다. 따라서 from에 다시 출금한 금액을 입금해야 합니다. 물론 출금 도중에 문제가 발생할 수도 있으므로 이체 함수는 아마 아래처럼 표현할 수 있겠습니다.

void transfer(Account from, Account to, int amount) {
    lock();
    try {
        from.withdraw(amount);
    } catch (e) {
        unlock();
        return;
    }
    try {
        to.deposit(amount);
    } catch (e) {
        from.deposit(amount);
        unlock();
        return;
    }
    unlock();
}

 비록 자바처럼 동기화 매커니즘이 언어에서 제공되는 경우라 해도 원래 데이터를 복구하기 위한 위와 같은 예외 처리는 필수적인 사항입니다.(기껏해야 lock(), unlock()함수가 생략될 뿐이지요...) 더 최악인 것은 위의 함수조차도 예외에 안전하지 않다는 것입니다. 만약 위 함수에서 빨간색으로 강조된 from.deposit(amount) 함수마저 예외가 발생한다면 from 고객은 상당히 기분이 나쁘겠죠... 따라서 위 함수가 정말로 예외에 안전하려면 아래와 같이 구현해야 합니다.

void transfer(Account from, Account to, amount) {
    lock();
    Account tempFrom(from), tempTo(to);
    try {
        tempFrom.withdraw(amount);
        tempTo.deposit(amount);
    } catch (e) {
        unlock();
        return;
    }
    swap(tempFrom, from);
    swap(tempTo, to);
    unlock();
}

 위 함수는 물론 swap함수가 예외가 발생하지 않는다는 조건하에 예외 안정적입니다. 어쨌든 참으로 귀찮은 작업이 아닐 수 없습니다.

 하지만 하스켈 STM은 이미 예외에 충분히 안정적입니다. 왜냐하면 트랜잭션의 특성상 중간 상태가 없기 때문입니다. 단지 전체 성공 아니면 전체 실패일 뿐이지요...

 결론적으로 하스켈 STM은 강력한 정적 타입 시스템과 트랜잭션의 안정성, 대상과 행위를 분리한 동기화 매커니즘이 잘 조합되어 매우 안정적이고 쉬운 병렬 프로그래밍을 가능하게 합니다. 여기에 덧붙여 하스켈 STM은 몇 가지 단순하면서도 강력한 함수들을 제공하여 block 이나 select 와 같은 추가적인 동기화 기능을 제공합니다. 그리고 이에 대한 설명은 다음 글에서 이어가도록 하겠습니다.

덧글

  • 성훈 2007/08/04 14:41 # 삭제 답글

    간만에 왔다가본다. 요즘 글들이 많이 어려워졌다. 프로그램어쩌고 저쩌고..ㅋㅋ 괜히 일 열심히 하고 그래보인다. ㅋ
    써클 홈피에 갔다가 글읽고 잠깐 들리는 거라우. 후배들 잘챙겨주는 게 부럽다.. 에이 나도 서울이면 같이 보고 그럼 좋은데..
    휴가는 가는공? 더운 여름 건강 잘 챙기면서 보내라..
  • silverbird 2007/08/09 00:18 # 답글

    // 성훈
    너도 잘 지내라...심심하면 휴가내서 놀러갈까? 발전소 구경시켜주나?
  • 성훈 2007/08/13 23:19 # 삭제 답글

    심심해도 휴가내서 오지마라.. 여긴 남초지역이다.ㅋㅋ 눈이 휘둥그레지는 서울에서 여자들 많이 보면서 휴가를 즐겨라. 난 인생이 여자랑은 아닌가보더라고.. 너의 진심어린 충고에도 불구하고.. 아직도 아무런 변화가 없는걸 보면 마흔은 되야 여자를 만날듯하다. ㅋ 잘지내라.
  • silverbird 2007/08/14 09:59 # 답글

    //성훈
    대략 안습이다...ㅜㅡ
    그럼 니가 여기로 휴가를 오든가...
댓글 입력 영역