ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 10. 커널 동기화 방법
    Operating System 2019. 12. 14. 00:49

    원자적 동작

    동기화 방법들 중 다른 동기화 방법의 기반이 되는 것이 바로 원자적 동작이다.

     

    원자적 동작은 중단 없이 한 단위로 실행되는 명령이다. 더 이상 쪼갤 수 없는 입자를 원자라고 하듯이, 원자적 동작은 더 이상 나눌 수 없는 명령이다.

     

    커널은 두 종류의 원자적 동작 인터페이스를 제공한다.

    첫 번째는 정수 연산을 위한 것이고 두번째는 개별 비트 연산을 위한 것이다.

     

    1. 원자적 정수 연산

    원자적 정수 연산은 특별한 자료구조인 atomic_t를 사용한다. 일반적인 C의 int형 대신 특별한 자료구조를 사용하는 데는 몇 가지 이유가 있다. 우선 원자적 함수의 인자로 atomic_t 형을 사용함으로써 다른 자료형에 원자적 함수를 잘못 사용하는 것을 막을 수 있다. 또한 비 원자적 함수에 원자적 정수를 잘못 사용하는 것도 막을 수 있다. 어찌 보면 당연 한말...;;

    다음으로는 atomic_t를 사용함으로써 컴파일러가 이 자료형 접근을 최적화하는 것을 막을 수 있다. 원자적 동작은 alias가 아닌 실제 메모리 주소를 사용해야 하기 때문이다.(volatile)

     

    원자적 함수는 보통 인라인 어셈블리를 사용한 인라인 함수로 구현된다. 동작 방식이 본질적으로 원자적인 일부 함수는 단순한 매크로 형태로 구현된다. 대부분의 아키텍처에서 워드 크기의 읽기 작업은 항상 원자적 동작이다. 즉 단일 워드에 쓰기 작업을 진행하는 도중에는 해당 워드에 대한 읽기 작업을 진행할 수 없다. 일기 작업은 항상 안정적인 상태의, 다시 말해 쓰기 작업이 진행되기 이전이나 이후의, 워드 정보를 알려주며, 절대 쓰기 작업이 진행 중인 상태의 워드 정보를 알려주지 않는다. 

     

    코드를 작성할 때는 가능하면 복잡한 락 대신 간단한 원자적 연산을 사용하는 것이 좋다. 대부분의 아키텍처에서 복잡한 동기화 방법 하나보다 원자적 연산 한 두 개가 월씬 가벼우며, 캐시 메모리 히트율도 더 높다. 하지만 성능에 민감한 코드라면 항상 여러 가지 방법을 모두 시험해 보는 것이 현명하다.

     

    2. 원자적 비트 연산

    커널은 원자적 정수 연산뿐 아니라 비트 단위 동작 함수도 제공한다. 이 함수는 포인터와 비트 번호를 인자로 받는다.

    일반적인 메모리 포인터를 사용하는 함수이므로 원자적 정수의 atomic_t와 같은 별도 자료형을 사용하지 않는다. 어떠한 데이터 포인터라도 사용이 가능하다.

    편의를 위해 원자적 비트 연산 함수와 같은 동작을 하는 비원자적 비트 연산 함수도 제공된다. 이런 함수는 원자적 함수와 같은 동작을 하지만 원자성을 보장하지 않으며, 앞부분에 두 개의 밑줄이 붙어 있다. ex) test_bit() , __test_bit()

    락을 통해 이미 데이터가 보호된 경우라면 원자성이 필요 없는 비원자적 함수가 더 빠를 수 있다.

     

    만일 경쟁 조건이 발생할 수 없는 경우라면 아키텍처에 따라 더 빠르게 동작하는 비원자적 연산을 사용할 수 있다.


    스핀락

    데이터를 구조체에서 제거하고, 재가공하거나 분석한 다음 다른 구조체에 추가하는 작업이 필요한 경우를 예로 들 수 있다.

     이 작업 전체를 원자적으로 실행해야 한다. 갱신 과정이 끝나기 전에 다른 코드가 두 구조체를 읽거나 쓰면 안 된다. 이러한 복잡한 상황에서는 간단한 원자적 연산만으로는 충분한 보호를 제공할 수 없기 때문에 더 일반적인 동기화 방법인 락이 필요하다.

     

    리눅스 커널에서 가장 일반적으로 사용하는 락은 스핀락이다. 스핀락은 최대 하나의 스레드만 잡을 수 있는 락이다. 실행 스레드가 이미 사용 중인 스핀락을 얻으려고 하면, 두 번째 스레드는 루프를 돌면서 락을 얻을 수 있을 때까지 기다린다. 락이 사용 중이 아닌 경우에는 바로 락을 얻을 수 있고 진행을 계속한다. 이러한 스핀과정을 통해 하나 이상의 스레드가 동시에 위험 지역(공통 자원)에 들어가는 것을 막을 수 있다. 같은 락은 여러 곳에서 사용할 수 있기 때문에 특정 자료구조의 모든 접근을 동기화하는 것이 가능하다.

     

    그런데 이미 사용 중인 스핀락으로 인해 다른 스레드가 락을 얻을 수 있는지 확인하면서 스핀한다는(프로세서 시간을 소모하면서 기다림) 사실이 가장 중요한 부분이다. 이 동작이 스핀락의 핵심이다. 스핀락을 오랫동안 잡고 있는 것은 바람직하지 않다. 이것이 바로 스핀락의 본질이다. 스핀락은 소유자가 하나인 가벼운 락으로서 단기간만 사용해야 한다. 락이 사용 중일 때 취할 수 있는 다른 방법으로, 스레드를 휴면시키고 락을 사용할 수 있을 때 깨우는 방법이 있다. 이러면 프로세스가 다른 코드를 실행하러 갈 수 있다. 이로 인해 추가적인 부담, 특히 중단된 스레드를 처리하는 두번의 컨텍스트 전환이 필요하게 되는데, 이 작업은 스핀락을 구현하는 필요한 코드보다 훨씬 많은 코드가 필요하다.(즉 오래걸림) 따라서 스핀락을 사용하는 시간이 두 번의 컨텍스트 전환 시간보다 짧은 것이 바람직하다. 컨텍스트 전환 시간 측정보다 더 가치 있는 일이 많으므로, 락 사용 시간은 가능한 짧아야 한다라고 생각하면 된다.

     

    1. 스핀락 사용 방법

    스핀락은 인터럽트 핸들러에서도 사용할 수 있지만, 세마포어는 휴면 상태로 전환될 수 있어 사용할 수 없다. 인터럽트 핸들러에서 락을 사용할 경우에는 락을 얻기 전에 해당 프로세서의 인터럽트를 반드시 비활성화해야 한다. 그렇지 않으면 인터럽트 핸들러가 이미 락을 사용하는 다른 커널 코드를 중단시키고 락을 얻으려고 시도할 수 있기 때문이다. 인터럽트 핸들러는 락이 풀릴 때까지 스핀하게 된다. 하지만 락을 잡고 있는 코드는 인터럽트 핸들러가 종료될 때까지 실행되지 않는다. 이것이 바로 이중 요청으로 인한 데드락의 예다. 현재 프로세서의 인터럽트만 비활성화시키면 된다. 다른 프로세서에서 인터럽트가 발생해 같은 락을 얻으려고 하는 경우에는 락을 가진 코드를 방해하지 않아서 결국 락을 풀 수 있기 때문이다. 그래서 커널은 인터럽트를 비활성화하고 락을 얻는 편리한 인터페이스를 제공한다.

     

    함수 설명
    spin_lock() 지정한 락을 건다
    spin_lock_irq() 현재 프로세서의 인터럽트를 비활성화 시키고 지정한 락을 건다
    spin_lock_irqsave() 현재 프로세서의 인터럽트 상태(interrupt enable 여부)를 저장하고 비활성화시킨 다음 지정한 락을 건다.
    spin_unlock() 지정한 락을 푼다.
    spin_unlock_irq() 지정한 락을 풀고, 현재 프로세서의 인터럽트를 활성화 시킨다.
    spin_unlock_irqrestore() 지정한 락을 풀고, 현재 프로세서의 인터럽트를 이전 상태로 복원한다.
    spin_lock_init() 지정한 spinlock_t 포인터를 초기화한다.
    spin_trylock() 지정한 락을 거는 작업을 시도하고, 실패하면 0이 아닌 값을 반환한다.
    spin_is_locked() 지정한 락이 현재 사용 중이면 0이 아닌 값을 반환하고, 그렇지 않으면 0을 반환한다.

     

    2. 스핀락과 후반부 처리

    spin_lock_bh() 함수는 지정한 락을 걸고 모든 후반부 처리 작업을 비활성화시킨다. spin_unlock_bh() 함수는 그 반대로 동작한다. 후반부 처리는 프로세스 컨텍스트 코드를 선점할 수 있기 때문에 후반부 처리와 프로세스 컨텍스 간에 공유하는 데이터가 있다면, 데이터를 보호하기 위해서는 락을 걸고 후반부 처리를 비활성화시켜야 한다. 마찬가지로 인터럽트 핸들러가 후반부 처리 작업을 선점할 수 있기 때문에, 인터럽트 핸들러와 후반부 처리 사이에 공유하는 데이터가 있다면, 데이터를 보호하기 위해서 락을 걸고 인터럽트 처리를 비활성화시켜야 한다.

     

    같은 형식의 태스크릿은 동시에 실행되지 않는다는 사실을 기억하자. 그러므로 특정 형식의 태스크릿 내부에서만 사용하는 데이터는 보호할 필요가 없다. 하지만 서로 다른 태스크릿 간에 데이터를 공유하는 경우에는 해당 데이터를 후반부 처리에서 사용하기 전에 반드시 스핀락을 걸어야 한다. 태스크릿이 같은 프로세스에서 실행되는 다른 태스크릿을 선점하는 일은 발생하지 않으므로, 후반부 처리를 비활성화 시킬 필요는 없다.

     

    softirq의 경우에는 형식 일치 여부와 상관없이 락을 이용해 softirq 사이에 공유하는 데이터를 보호해야 한다. softirq는 형식이 같은 경우에도 여러 프로세서에서 동시에 실행될 수 있다. 하지만 softirq 역시 같은 프로세서에 있는 다른 softirq를 선점하는 일은 없으므로 후반부 처리를 비활성화시킬 필요는 없다.


    리더-라이터 스핀락

    락 사용 형태가 리더와 라이터로 명확하게 구분되는 경우가 있다. 내용을 변경하기도 하고 탐색하기도 하는 리스트를 예로 들어 보자. 리스트 내용을 번경할 때는(쓰기 작업) 다른 실행 스레드가 동시에 리스트에 쓰기나 읽기 작업을 하지 못하게 해야 한다. 쓰기 작업에는 상호 배제가 필요하다. 반면 리스트를 탐색하는 경우에는(읽기 작업) 다른 스레드가 동시에 리스트에 쓰는 작업만 막기만 하면 된다. 쓰기 작업만 없다면 여러 개의 읽기 작업을 실행해도 안전하다.

     

    자료구조의 사용 방식이 리더/라이터 또는 생산자/소비자 형태로 깔끔하게 나뉘는 경우라면 비슷한 동작을 가지고 있는 락 방식을 사용하는 것이 좋다. 이런 경우를 위해 리눅스는 리더-라이터 스핀락을 제공한다. 리더-라이터 스핀락은 별도의 리더 락과 라이터 락으로 구성된다. 하나 이상의 리더가 동시에 리더 락을 걸 수 있다. 반면 라이터 락을 리더 락이 없는 경우 하나의 라이터만 사용할 수 있다. 리더/라이터 락은 (리더 입장에서는) 공유하거나 (라이터 입장에서는) 독점하는 모양새가 되기 때문에 공유락/독점락 또는 동시락/독점락이라고 부르기도 한다.

     

    보통 리더 락을 사용하는 부분과 라이터 락을 사용하는 부분은 완전히 분리된다.

    리더 락을 라이터 락으로 전환할 수는 없다. 다음 코드를 보자

     

    read_lock(&mr_rwlock);

    write_lock(&mr_rwlock);

     

    이 두 함수를 실행하면 라이터 락이 자신을 사용하는 리더 락을 포함한 모든 리더 락이 풀리기를 기다리며 스핀하게 되어 데드락에 빠진다. 쓰기가 필요한 상황이라면 처음부터 라이터 락을 걸어야 한다. 만약 리더와 라이터 코드가 섞이게 되는 경우가 있다면, 이는 리더-라이터 락을 사용하는 것이 적절하지 않다는 신호일 수 있다. 이 때는 일반적인 스핀락을 사용하는 편이 좋다.

     

    리더 락은 여러 번 거는 것은 안전하다. 한 스레드가 재귀적으로 같은 리더 락을 여러 번 거는 것도 문제가 없다. 이는 유용한 특성으로 최적화에도 사용할 수 있다. 인터럽트 핸들러 내에 라이터가 없고 리더만 있다면 인터럽트를 비활성화시키는 락을 섞어서 사용할 수 있다. read_lock_irqsave() 대신 read_lock() 함수를 사용해 읽기 작업을 보호할 수 있다. 하지만 쓰기 작업을 하는 경우에는 write_lock_irqsave()를 사용해 인터럽트를 비활성화시켜야 한다. 그렇지 않으면 인터럽트 핸들러의 리더 부분으로 인해 라이터 락을 얻는 부분이 데드락에 빠질 수 있다. 

     

    함수 설명
    read_lock() 지정한 리더 락을 건다.
    read_lock_irq() 현재 프로세서의 인터럽트를 비활성화시키고, 지정한 리더 락을 건다.
    read_lock_irqsave() 현재 프로세서의 인터럽트 상태를 저장하고 비활성화시킨 다음, 지정한 리더 락을 건다.
    read_unlock() 지정한 리더 락을 해제한다.
    read_unlock_irq() 지정한 리더락을 해제하고, 현재 프로세서의 인터럽트를 활성화시킨다.
    read_unlock_irqrestore() 지정한 리더 락을 하제하고, 현재 프로세서의 인터럽트를 이전 상태로 복구한다.
    write_lock() 지정한 라이터 락을 건다.
    write_lock_irq() 현재 프로세서의 인터럽트를 비활성화시키고, 지정한 라이터 락을 건다.
    write_lock_irqsave() 현재 프로세서의 인터럽트 상태를 저장하고 비활성화시킨 다음, 지정한 라이터 락을 건다.
    write_unlock() 지정한 라이터 락을 해제한다.
    write_unlock_irq() 지정한 라이터 락을 해제하고, 현재 프로세서의 인터럽트를 활성화시킨다.
    write_unlock_irqrestore() 지정한 라이터 락을 해제하고, 현재 프로세서의 인터럽트를 이전 상태로 복구한다.
    write_trylock() 지정한 라이터 락 거는 것을 시도한다. 락을 걸 수 없으면 0이 아닌 값을 반환한다.
    rwlock_init() 지정한 rwlock_t를 초기화한다.

     

    마지막으로 한 가지 짚고 넘어갈 점은, 리눅스의 리더-라이터 스핀락의 경우 라이터보다 리더의 우선순위가 높다는 점이다. 리더 락이 걸려 있고, 라이터 락이 배타적 접근을 위해 대기 중이라 하더라도, 또 다른 리더가 리더 락을 걸 수 있다는 것이다. 모든 리더가 락을 풀어야 대기 중인 라이터 락을 걸 수 있다. 따라서 리더의 수가 충분히 많으면 라이터가 영원히 락을 얻을 수 없는 상황이 벌어질 수 있다. 락을 설계할 때는 이런 점을 충분히 고려해야 한다. 상황에 따라서는 이런 동작이 도움이 될 수도 있지만, 재앙이 될 수도 있다.

     

    스핀락은 빠르고 간단하다. 락을 거는 시간이 짧고 (인터럽트 핸들러처럼) 휴면 상태로 전환이 불가능한 상황이라면 스핀 동작이 적절하다. 하지만, 락을 거는 시간이 길어질 수 있거나 락을 건 상태에서 휴면 상태로 전환될 가능성이 있는 경우라면 세마포어가 해결 책이 될 수 있다.


    세마포어

    리눅스의 세마포어는 휴면하는 락이라고 생각하면 된다. 태스크가 이미 사용 중인 세마포어를 얻으려고 하면, 세마포어는 해당 태스크를 대기 큐에 넣고 휴면 상태로 만든다. 그 다음 프로세서는 자유롭게 다른 코드를 실행한다. 세마포어가 사용 가능해지면, 대기큐의 태스크 하나를 깨우고, 이 태스크가 세마포어를 사용하게 된다.

     

    세마포어는 무의미한 루프를 돌면서(스핀하면서) 낭비하는 시간이 없어지기 때문에 프로세서 활용도가 높아진다. 하지만 세마포어는 스핀락보다 부가 작업이 훨씬 많다. 인생은 항상 받은게 있으면 주는 것도 있기 마련이다.

     

    세마포어의 휴면 동작으로부터 다음과 같은 재미있는 결론을 끌어낼 수 있다.

    • 락을 기다리는 태스크가 휴면 상태로 전환되므로, 세마포어는 오랫동안 락을 사용하는 경우에 적합하다.
    • 반대로 락 사용 시간이 짧은 경우에는 휴면 상태 전환 및 대기 큐 관리, 태스크 깨우기 등의 부가 작업을 처리하는 시간이 락 사용 시간을 넘어설 수 있기 때문에, 세마포어 사용이 적절하지 않다.
    • 락이 사용 중이면 실행 스레드가 휴면 상태로 전환되기 때문에, 스케줄링이 불가능한 인터럽트 컨텍스트가 아닌 프로세스 컨텍스트에서만 세마포어를 사용할 수 있다.
    • 다른 프로세스가 같은 세마포어를 얻으려고 하는 경우라도 데드락에 빠지는 일이 발생하지 않으므로 락을 잡은 상태에서 휴면 상태로(의도치 않은 상황에서도) 전환할 수 있다. 락을 잡으려는 다른 프로세스도 휴면 상태로 전환되므로, 결국은 실행이 계속된다.
    • 세마포어를 얻기 위해서는 휴면 상태로 전환될 수 있는데, 스핀락이 걸린 상태에서는 휴면 상태가 될 수 없으므로, 세마포어를 얻으려고 할 때에는 스핀락이 걸려 있으면 안 된다.

     

    이를 통해 세마포어와 스핀락의 분명한 차이점을 알 수 있다. 세마포어를 사용하는 대부분의 경우에는 다른 락을 사용할 수 없는 상황이다. 동기화를 사용하는 사용자 공간 코드와 마찬가지로 휴면이 필요한 상황이라면, 사용할 수 있는 방법은 세마포어뿐이다. 그리고 세마포어를 사용하면 휴면이 가능하다는 유연성을 제공하기 때문에 꼭 필요하지 않더라도 코드 작성이 편해지는 측면이 있다. 세마포어와 스핀락 사이에서 선택이 가능한 상황이라면, 락을 잡고 있는 시간으로 결정해야 한다. 락은 항상 가능한 짧은 시간 동안 잡는 것이 이상적이다. 하지만, 세마포어를 사용하는 경우에는 락 잡는 시간이 좀 더 길어지는 것을 허용할 수 있다. 게다가 스핀락과 달리 세마포어는 커널 선점을 비활성화시키지 않기 때문에, 세마포어를 잡고 있는 코드도 선점이 될 수 있다. 이는 세마포어가 스케줄링 지연시간에 부정적인 영향을 미치지 않는다는 것을 뜻한다.

     

    1. 카운팅 세마포어와 바이너리 세마포어

    세마포어의 또 다른 장점은 동시에 여러 스레드가 같은 락을 얻을 수 있다는 점이다. 스핀락은 한 번에 단 하나의 태스크만 락을 얻을 수 있는 반면, 세마포어는 선언할 때 동시에 허용하는 락의 숫자를 지정할 수 있다. 이 값을 사용 카운트 또는 그냥 카운트라고 부른다. 대부분은 스핀락 처럼 하나의 스레드만이 락을 사용할 수 있게 이 값을 설정한다. 카운트 값이 1이 되는 이 같은 경우의 세마포어를 바이너리 세마포어(태스크 하나가 락을 잡고 있거나, 잡고 있지 않거나 두 가지 상태를 가지므로) 또는 뮤텍스(상호 배제를 강제하므로)라고 부른다. 한편 1보다 큰 0이 아닌 값을 카운터로 지정할 수 있다. 이 경우의 세마포어를 카운팅 세마포어라고 부르며, 최대 지정한 카운트만큼의 락 소유자가 있을 수 있다. 카운팅 세마포어를 사용할 경우에는 다수의 스레드가 동시에 같은 위험구역에 진입할 수 있으므로 상호 배제가 보장되지 않는다. 그보다는 특정 코드의 한계를 지정하는 데 사용한다. 카운팅 세마포어는 커널에서 그리 많이 사용되지 않는다. 커널에서 세마포어를 사용한다라고 하면 대부분(카운터가 1인 세마포어인) 뮤텍스를 사용하는 것이다.

     

    리눅스를 비롯한 이후의 시스템에서는 이를 각각 down()과 up()으로 부르고 있다. down() 함수는 세마포어를 얻을 때 사용하는데, 실제 과정은 카운트 값을 1 줄임으로써 이루어진다. 만약 카운트가 0 이상이면 태스크는 락을 성공적으로 얻고, 위험 직역으로 진입한다. 카운트가 음수이면 태스크를 대기큐에 넣고 프로세서가 다른 일을 하도록 한다. 위험 지역에서 작업을 끝내고 세마포어를 반납할 때는 up() 함수를 사용한다. 이를  세마포어를 띄운다(up)라고 하기도 한다. up함수는 카운트 값을 증가시킨다. 세마포어의 대기큐에 대기 중인 태스크가 있으면, 태스크를 깨워서 세마포어를 얻도록 한다.

     

    2. 세마포어 생성과 초기화

    세마포어는 아키텍처에 따라 다르게 구현되며, 그 내용은 <asm/semaphore.h>에 들어있다. 세마포어를 표현하는 구조체는 struct semaphore이다. 세마포어를 정적으로 선언하는 경우에는 다음과 같이 사용한다. 여기서 name은 세마포어 변수의 이름이고, count는 세마포어의 카운트 값이다.

     

    truct semaphore name;

    sema_init(&name, count);

     

    일반적으로 더 많이 사용하는 뮤텍스를 생성하는 경우에는 다음과 같이 좀 더 간단한 방법을 사용할 수 있다. 역시 name은 뮤텍스 변수의 이름이다.

     

    static DECLARE_MUTEX(name);

     

    세마포어는 다른 커다란 구조체의 일부분으로서 동적으로 생성되는 경우가 훨씬 많다. 이 경우에는 동적으로 만들어진 세마포어를 가리키는 포인터를 이용해, 다음과 같이 sema_init() 함수를 호출함으로써 초기화할 수 있다. 여기서 sem은 세마포어를 가리키는 포인터이고 count는 세마포어의 카운트 값이다.

     

    sema_init(sem, count);

     

    동적으로 생성된 뮤텍스도 비슷하게 다음과 같이 초기화할 수 있다.

     

    init_MUTEX(sem);

     

    3. 세마포어

    down_interruptible() 함수를 사용해 지정한 세마포어를 얻을 수 있다. 세마포어를 얻을 수 없는 경우에는 호출 프로세스의 상태를 TASK_INTERRUPTIBLE 상태로 바꾸어 휴면 상태로 전환한다. 이 상태에 있는 태스크는 시그널을 이용해 깨울 수 있다. 태스크가 세마포어를 기다리는 도중 시그널이 발생하면, down_interruptible() 함수는 휴면 상태에서 벗어나 -EINTR를 반환한다. 이와 달리 down() 함수는 휴먼 시 TASK_UNINTERRUPTIBLE 상태로 바꾼다. 세마포어를 기다리는 동안 프로세스가 시그널에 반응하지 않을 테니, 이런 상태로 바꾼다. 세마포어를 기다리는 동안 프로세스가 시그널에 반응하지 않을 테니, 이런 상황을 원하는 경우는 별로 없을 것이다. 그래서 down() 보다는 down_interruptible() 함수를 훨씬 더 자주(그리고 바람직하게) 사용한다.

     

    down_trylock() 함수를 사용하면 대기 상태에 빠지는 일 없이 지정한 세마포어 획득을 시도할 수 있다. 세마포어가 사용 중이면 이 함수는 즉시 0이 아닌 값을 반환한다. 성공적으로 락을 획득하면 0을 반환한다. 지정한 세마포어를 해제할 때는 up() 함수를 사용한다. 다음 예를 살펴보자

     

    // 이름이 mr_sem이고, 카운트 값이 1인 세마포어를 선언한다.

    static DECLARE_MUTEX(mr_sem);

     

    // 세마포어 획득을 시도한다.

    if(down_interruptible(&mr_sem)){

    // 시그널을 받은 경우에는 세마포어를 얻지 못한다

    }

     

    // 위험지역

     

    // 지정한 세마포어를 해제한다.

    up(&mr_sem);

     

    세마포어 함수

    함수 설명
    sema_init(struct semaphore *, int) 동적으로 생성된 세마포어를 지정한 카운트 값으로 초기화한다.
    init_MUTEX(struct semaphore *) 동적으로 생성된 세마포어를 카운트 값 1로 초기화 한다.
    init_MUTEX_LOCKED(struct semaphore *) 동적으로 생성된 세마포어를 카운트 값 0으로(즉 이미 잠긴 상태로) 초기화한다.
    down_interruptible(struct semaphore *) 세마포어 획득을 시도하고, 얻을 수 없는 경우 인터럽트 가능한 휴면 상태로 전환한다.
    down(struct semaphore *) 세마포어 획득을 시도하고, 얻을 수 없는 경우 인터럽트 불가능한 휴면 상태로 전환한다.
    down_trylock(struct semaphore *) 세마포어 획득을 시도하고, 얻을 수 없는 경우 바로 0이 아닌 값을 반환한다.
    up(struct semaphore *) 지정한 세마포어를 반납하고, 대기 중인 태스크가 있으면 깨운다.

     


    리더-라이터 세마포어

    스핀락과 마친가지로 세마포어에도 리더-라이터 세마포어가 있다. 표준 세마포어보다 리더-라이터 세마포어 사용이 더 적절한 경우는 표준 스핀락 대신 리더-라이터 스핀락이 더 적절한 경우와 동일하다.

     

    리더-라이터 세마포어는 <linux/rwsem.h>에 정의된 struct rw_semaphore 자료형을 사용한다. 리더-라이터 세마포어를 정적으로 선언하는 방법은 다음과 같다. 여기서 name은 새로 선언하는 세마포어의 이름이다.

    static DECLARE_RWSEM(name);

     

    동적으로 생성한 리더-라이터 세마포어는 다음 방법으로 초기화한다.

    init_rw_sem(struct rw_semaphore *sem);

     

    리더-라이터 세마포어는 리더가 아닌 라이터의 경우에만 상호 배제성을 유지하지만, 모든 리더-라이터 세마포어는 사용 카운트 값이 1인 뮤텍스다. 라이터가 없는 한 다수의 리더가 리더 락을 얻을 수 있다. 반면, (리더가 없는 상황에서) 하나의 라이터만이 이 라이터 락을 얻을 수 있다. 리더-라이터 락은 항상 인터럽트 불가능한 휴면 상태를 사용하므로 리더 락, 라이터 락 각각에 대해 한 가지 종류의 down() 함수만 존재한다.

    static DECLARE_RWSEM(mr_rwsem);

     

    // 읽기 작업을 위해 세마포어 획득을 시도

    down_read(&mr_rwsem);

     

    // 위험 지역

     

    // 세마포어 반납

    up_read(&mr_rwsem);

     

    // 쓰기 작업을 위해 세마포어 획득 시도

    down_write(&mr_rwsem);

     

    // 위험 지역

     

    // 세마포어 반납

    up_write(&mr_rwsem);

     

    세마포어와 마찬가지로 down_read_trylock(), down_write_trylock() 함수도 있다. 각 함수는 리더-라이터 세마포어 포인터 하나의 인자를 사용한다. 락을 성공적으로 얻은 경우에는 0이 아닌 값을 반환하고, 락이 사용 중인 경우에는 0을 반환한다. 뚜렷한 이유가 없음에도 불구하고, 이런 동작은 일반적인 세마포어 함수들과 반대이기 때문에 주의가 필요하다.

     

    리더-라이터 세마포어에는 리더-라이터 스핀락에는 없는 특별한 함수가 하나 있는데, downgrade_writer() 함수다. 이 함수는 획득한 라이터 락을 원자적으로 리더 락으로 바꿔준다.

     

    스핀락의 경우와 마찬가지로, 리더-라이터 세마포어도 읽지 작업을 위한 코드와 쓰기 작업을 위한 코드가 명확하게 나뉘어 있는 경우에만 사용해야 한다. 리더-라이터 구조를 사용하는 데도 비용이 들어가므로 읽는 부분과 쓰는 부분의 코드가 태생적으로 분리되어 있는 경우에만 사용하는 것이 좋다.

     


    뮤텍스

    뮤텍스는 카운트 값이 1인 세마포어 처럼 상호 배제성을 가진 휴면 가능한 락을 가리키는 말이다.

    뮤텍스는 struct mutex 자료구조를 사용한다. 뮤텍스는 카운트 값이 1인 세마포어와 유사하게 동작하지만, 인터페이스가 더 간단하고, 성능도 더 효율적이며, 사용상 부가적인 제약 사항을 가지고 있다.

     

    정적으로 뮤텍스를 선언하는 방법은 다음과 같다.

    DEFINE_MUTEX(name);

     

    동적으로 뮤텍스를 초기화하는 경우에는 다음 함수를 호출한다.

    mutex_init(&mutex);

     

    뮤텍스를 걸고 해제하는 방법은 간단하다.

    mutex_lock(&mutex);

    // 위험 지역

    mutex_unlock(&mutex);

     

    함수 설명
    mutex_lock(struct mutex *) 지정한 뮤텍스를 얻는다. 락을 얻을 수 없으면 휴면 상태로 전환한다.
    mutex_unlock(struct mutex *) 지정한 뮤텍스를 해제한다.
    mutex_trylock(struct mutex *)

    지정한 뮤텍스 획득을 시도, 성공적으로 락을 얻으면 1을 반환, 얻지 못한 경우에는 0을 반환한다.

    mutex_is_locked(struct mutex *) 락이 사용 중이면 1을 반환, 사용 중이 아니면 0을 반환한다.

     

    뮤텍스의 단순함과 효율성은 세마포어가 제약이 더 많아서 가능하다. 다익스트라의 애초 설계에 맞추어 가장 기본적인 동작만을 구현한 세마포어와 달리 뮤텍스는 사용조건이 더 엄격하고 제한적이다.

     

    • 한 태스크는 한 번에 하나의 뮤텍스만 얻을 수 있다. 즉 뮤텍스의 사용 카운트 값은 항상 1이다.
    • 뮤텍스를 얻은 곳에서만 뮤텍스를 해제할 수 있다. 즉 한 컨텍스트에서 얻은 뮤텍스를 다른 컨텍스트에서 해제할 수는 없다. 이는 뮤텍스는 커널과 사용자 공간 사이의 복잡한 동기화에 사용하는 것은 적당치 않다는 것을 뜻한다. 하지만 락을 얻고 해제하는 작업이 깔끔하게 같은 컨텍스트에서 이루어지는 경우가 대부분이다.
    • 재귀적으로 락을 얻고 해제할 수 없다. 즉 같은 뮤텍스를 재귀적으로 여러번 얻을 수 없으며, 해제된 뮤텍스를 다시 해제할 수없다.
    • 뮤텍스를 가지고 있는 동안에는 프로세스 종료가 불가능하다.
    • 인터럽트 핸들러나 후반부 처리 작업 내에서는 뮤텍스를 얻을 수 없으며, mutex_trylock() 함수도 사용할 수 없다.
    • 뮤텍스는 공식 API를 통해서만 초기화해야 하며, 뮤텍스를 복사하거나 초기화 상태를 전달하거나 다시 초기화하는 작업은 불가능하다.

     

    새로운 뮤텍스 구조체의 가장 유용한 부분은 아마도 특별한 디버깅 모드에서 커널이 이런 제약의 위반 여부를 자동으로 확인하고 알려줄 수 있다는 점일 것이다. CONFIG_DEBUG_MUTEXS 커널 옵션이 설정되어 있으면, 여러 단계의 디버깅 작업들이 이런 제약 사항이 잘 지켜지고 있는지 확인해 준다. 이를 이용해 간단하면서도 엄격한 사용 형식을 지키며 뮤텍스를 사용할 수 있다.

     

    1. 세마포어와 뮤텍스

    특정 서브시스템에서 사용하는 코드를 새로 작성할 때 세마포어가 필요한 경우가 많다. 뮤텍스로 시작한 다음 뮤텍스의 제약 사항 때문에 다른 선택이 필요한 경우에만 세마포어로 전환하는 것이 좋다.

     

    2. 스핀락과 뮤텍스

    스핀락과 뮤텍스(또는 세마포어) 중에 어느 것을 사용해야 하는가는 최적화된 코드를 작성하는 데 있어 중요한 문제다. 하지만 대부분의 경우 선택의 여지가 거의 없다. 인터럽트 컨텍스트에서는 스핀락만 사용할 수 있으며, 태스크가 휴면 가능한 경우는 뮤텍스만 사용할 수 있다. 

     

    스핀락과 세마포어 중 어떤 락을 사용할 것인가?

    요구사항 추천
    락 부담이 적어야 하는 경우 스핀락을 추천
    락 사용 시간이 짧은 경우 스핀락을 추천
    락 사용 시간이 긴 경우 뮤텍스 추천
    인터럽트 컨텍스트에서 락을 사용하는 경우 반드시 스핀락을 사용
    락을 얻은 상태에서 휴면할 필요가 있는 경우 반드시 뮤텍스를 사용

     


    완료 변수

    완료 변수는 커널의 한 태스크가 다른 태스크에 특정 이벤트가 발생했다는 것을 알려줄 필요가 있을 때 쉽게 두 태스크를 동기화시킬 수 있는 방법이다. 한 태스크가 작업을 수행하는 동안 다른 태스크는 완료 변수를 기다린다. 작업을 마치면 완료 변수를 이용해 대기 중인 태스크를 깨운다. 왠지 이 과정이 세마포어와 비슷하다고 생각했다면 바로 본 것이다. 거의 같은 개념이다. 사실 완료 변수는 세마포어가 필요한 문제에 대한 간단한 해결책에 불과하다. 예를 들면, vfork() 시스템 호출은 자식 프로세스 실행 및 종료 시에 부모 프로세스를 깨우는 수단으로 완료 변수를 사용한다.

    완료 변수는 <linux/completion.h>에 정의된 struct completion 구조체를 사용한다. 정적 완료 변수는 다음과 같은 방법으로 생성 및 초기화할 수 있다.

    DECLAER_COMPLETION(mr_comp);

     

    동적으로 생성한 완료 변수는 init_completion() 함수를 사용해 초기화한다. 특정 완료 변수를 기다려야 하는 태스크는 wait_for_completion() 함수를 호출하다. 기다리는 이벤트가 발생하면, complete() 함수를 호출해 대기 중인 태스크를 모두 깨운다.

     

    완료 변수 함수

    함수 설명
    init_completion(struct completion *) 동적으로 생성한 완료 변수를 초기화
    wait_for_completion(struct completion *) 지정한 완료 변수의 완료 신호를 기다림
    complete(struct completion *) 대기 중인 태스크를 깨우기 위해 신호를 보냄

     

    완료 변수의 사용 예는 kernel/sched.c과 kernel/fork.c에서 볼 수 있다. 완료 변수는 자료구조의 구성 요소로서 동적으로 생성해 사용하는 경우가 많다. 자료 구조 초기화를 기다리는 커널 코드가 wait_for_completion() 함수를 호출한다. 초기화가 끝나면 completion() 함수를 호출해 대기 중인 태스크를 깨운다.

     


    큰 커널 락

    이제 커널의 골칫덩이인 큰 커널 락(BKL, Big Kernel Lock)에 온 것을 환영한다. BKL은 리눅스의 초기 SMP 구현을 세밀한 락을 사용하는 지금의 구현 방식으로 전환하는 작업의 편의를 위해 도입한 전역 스핀락이다. BKL에는 몇 가지 재미있는 특징이 있다.

     

    • BKL을 얻은 채로 휴면 상태로 전환할 수 있다. 태스크가 스케줄링에서 제외되면 이 락은 자동으로 해제되고, 스케줄링되는 시점에서 다시 자동으로 얻어진다. 물론 이 말이 BKL을 얻은 채로 휴면하는 것이 항상 안전하다는 뜻은 아니며, 그렇게 할 수도 있어며, 그렇게 해도 데드락에 빠지지 않는다는 것뿐이다.
    • BKL은 재귀적인 락이다. 한 프로세스가 BKL을 여러 번 얻을 수 있으며, 스핀라과 달리 이렇게 해도 데드락에 빠지지 않는다.
    • BKL은 프로세스 컨텍스트에서만 사용할 수 있다. 스핀락과 달리 인터럽트 컨텍스트에서는 BKL을 얻을 수 없다.
    • 새로운 코드에 BKL을 사용하는 것은 금지된다. 매번 커널 새 버전이 배포될 때마다 BKL을 사용하는 드라이버와 서브시스템은 점점 줄어들고 있다.

     

    ?????????????????????????????????????????????????????????????????


    순차적 락

    seq 락이라고 줄여서 부르는 순차적 락은 2.6 커널에 도입된 새로운 종류의 락이다. 이 락은 공유 데이터를 읽고 쓰는 간단한 방식을 제공한다. seq 락은 순차 카운터 값을 가지고 동작한다. 대상이 되는 데이터에 쓰기 작업을 할 때마다 락을 얻고 순차 카운터 값을 증가시킨다. 데이터 읽기 작업 전과 작업 후에 순차 카운트 값을 읽는다. 두 값이 같다면, 읽기 작업 도중에 쓰기 작업이 일어나지 않았다는 것을 알 수 있다. 게다가 그 값이 짝수라면, 현재 읽기 작업이 진행 중이 아니라는 것도 알 수 있다. 락 카운터 값이 0부터 시작하기 때문에 쓰기 작업을 위해 락을 얻으면 값이 홀수가 되고, 락을 해제하면 다시 짝수가 된다.

     

    seq 락은 다음과 같이 정의한다.

    seqlock_t_mr_seq_lock = DEFINE_SEQLOCK(mr_seq_lock);

     

    쓰기 작업을 하는 경우의 코드는 다음과 같다.

    write_seqlock(&mr_seq_lock);

    // 쓰기 작업을 위한 락을 얻음

    write_sequnlock(&mr_seq_lock);

     

    이 부분은 일반적인 스핀락 코드와 비슷하다. 읽기 작업을 위한 코드는 상당히 다른 모습을 보여준다.

    unsigned long seq;

     

    do{

    seq = read_seqbegin(&mr_seq_lock);

    // 이제 데이터를 읽는다.

    }while(read_seqretry(&mr_seq_lock, seq));

     

    seq 락은 리더가 많고 라이터가 거의 없는 경우에 사용할 수 있는 가볍고 확장성이 좋은 락이다. 단, seq 락은 리더보다 라이터의 우선순위가 높다. 다른 라이터가 없는 한, 라이터락 획득 시도는 항상 성공한다. 리더-라이터 스핀락이나 리더-라이터 세마포어의 경우와 마찬가지로 리더는 라이터 락에 아무런 영향을 끼치지 못한다. 더욱이 대기 중인 라이터가 계속 존재하면 락을 잡고 있는 라이터가 다 사라질 때까지 읽기 루프가 반복된다.

     

    seq 락은 다음의 모든 또는 대부분의 조건을 만족시키는 경우에 이상적으로 사용할 수 있는 락이다.

    • 데이터의 읽기 작업이 아주 많다.
    • 데이터의 쓰기 작업은 거의 없다.
    • 쓰기 작업이 거의 없긴 하지만, 읽기 작업으로 인해 쓰기 작업이 지연되는 일이 절대 벌어지지 않도록 쓰기 작업의 우선순위가 높아야 한다
    • 간단한 구조체나 정수 하나와 같이 간단한 데이터지만, 어떤 이유로 인해 원자적 동작이 불가능하다.

     

    seq 락을 주로 사용하는 경우는 리눅스 시스템 가동시간을 저장하는 jiffies 변수다. jiffies 변수는 시스템이 시작된 이후의 타이머 진동 횟수를 저장하는 64비트 카운터 값이다. jiffies_64 변수의 전체 64비트 값을 원자적으로 읽을 수 없는 시스템에서는 seq 락을 이용해 get_jiffies_64() 함수를 구현한다.

    u64 get_jiffies_64(void)

    {

    unsigned long seq;

    u64 ret;

     

    do{

    seq = read_seqbegin(&xtime_lock);

    ret = jiffies_64;

    }while(read_seqretry(&xtime_lock, seq));

     

    return ret;

    }

     

    타이머 인터럽트를 처리하는 동안 jiffies 값을 갱신하려면 라이터 seq락을 얻어야 한다.

    write_seqlock(&xtime_lock);

    jiffies_64 += 1;

    write_sequnlock(&xtime_lock);

     


    선점 비활성화

    선점형 커널이기 때문에 커널 내의 프로세스는 우선순위가 더 높은 프로세스를 실행하기 위해 언제라도 중단될 수 있다. 이는 선점당한 프로세스가 실행하고 있던 동일한 위험 지역에 새로운 태스크가 진입할 수 있다는 것을 뜻한다. 이를 막기 위해 커널 선점 코드는 스핀락을 사용해 선점 불가능한 지역을 표시한다. 이 스핀락이 사용 중이라면, 커널은 선점할 수 없는 상태가 된다. 커널 선점으로 인한 동시성 문제와 다중 프로세서로 인한 동시성 문제는 동일하며, 커널이 이미 SMP-safe 상태이기 때문에, 이 간단한 변경으로 커널을 선점-safe 상태가 된다.

     

    사실 그렇기를 바란다. 현실적으로는 스핀락은 필요하지 않지만, 커널 선점은 비활성화시켜야 하는 상황이 있다. 이런 상황이 가장 많이 발생하는 경우는 프로세서별 데이터의 경우다. 프로세서별로 사용하는 데이터의 경우라면, 한 프로세서만 해당 데이터에 접근하기 때문에 락을 이용해 보호할 필요가 없다. 스핀락을 사용하지 않으면 커널은 선점 가능한 상태이므로, 다음처럼 새로 스케줄링된 태스크가 이 데이터에 접근하는 일이 벌어질 수 있다.

     

    태스크 A가 락을 사용하지 않은 프로세서별 변수 foo를 조작

    태스크 A가 선점됨

    태스크 B가 스케줄링됨

    태스크 B가 변수 foo를 조작

    태스크 B가 완료

    태스크 A가 다시 스케줄링됨

    태스크 A가 변수 foo 조작 작업을 계속함

     

    결과적으로 단일 프로세서 시스템이라 하더라도 여러 프로세스가 해당 변수를 유사-동시적으로 접근할 수 있다. 보통 이런 경우(다중 프로세서 시스템의 진정한 동시성 문제를 막기 위해) 변수에 스핀락을 사용해야 한다. 하지만 이 변수가 프로세서별 변수라면, 락이 필요 없을 수 있다.

    이 문제를 해결하기 위해 preempt_disable() 함수를 통해 커널 선점을 비활성화시킬 수 있다. 이 함수는 중첩 호출이 가능하므로 여러 번 호출할 수 있다. 호출마다 그에 상응하는 preempt_enable() 함수가 필요하다. 마지막 preempt_enable() 함수가 호출된 뒤에야 커널 선점이 다시 활성화된다. 사용 예는 다음과 같다.

    preempt_disable();

    // 선점이 비활성화됨

    preempt_enable();

     

    락을 얻은 횟수와 preempt_disable() 함수 호출 횟수를 선점 카운터에 저장한다. 이 값이 0이면 커널은 선점 가능한 상태가 된다. 이 값이 1 이상이면, 커널은 선점 불가능한 상태가 된다. 이 카운터 값은 아주 중요하다. 원자성 검사와 휴면 상태 디버깅의 좋은 수단이다. preempt_count() 함수는 이 카운터 값을 반환한다.

     

    커널 선점 관련 함수

    함수 설명
    preempt_disable() 선점 카운터를 증가시켜 커널 선점을 비활성화한다.
    preempt_enable() 선점 카운터를 감소시키고 카운터 값이 0이면 대기 중인 작업이 있는지를 확인하고 재스케줄링한다.
    preempt_enable_no_resched() 커널 선점을 활성화시키되, 대기 중인 작업을 재스케줄링하지 않는다.
    preempt_count() 선점 카운터 값을 반환한다.

     

    프로세서별 데이터에 대한 더 깔끔한 해결책으로 get_cpu() 함수를 통해 프로세서 번호를 얻는, 그리고 이 번호를 프로세서별 데이터에 접근하는 인덱스 값으로 사용하는 방법이 있다. get_cpu() 함수는 현재 프로세서 번호를 반환하기 전에 커널 선점을 비활성화한다.

    int cpu;

     

    // 커널 선점을 비활성화하고, 'cpu' 변수에 현재 프로세서 번호를 설정

    cpu = get_cpu();

     

    // 프로세서별 데이터 조작

     

    // 커널 선점을 다시 활성화, 'cpu' 값이 바뀔 수 있으므로, 더 이상 유요한 값이 아니다.

     


    순차성과 배리어

    여러 프로세서 사이나, 하드웨어 장치 사이의 동기화를 처리할 때는 메모리 읽기 작업과 메모리 쓰기 작업이 프로그램 코드에서 지정한 순서대로 진행되어야 하는 경우가 있다. 하드웨어와 통신하는 경우, 다른 읽기, 쓰기 작업이 일어나기 전에, 특정 읽기 작업이 실행되어야 하는 경우가 많다. 특히 대칭형 다중 프로세서 시스템에서는 쓰기 작업도(보통 같은 순서로 이어지는 읽기 작업이 데이터를 읽을 수 있게 하기 위해) 코드에 지정한 순서대로 진행되어야 할 수도 있다. 이 문제를 복잡하게 만드는 것은 컴파일러와 프로세서가 성능을 이유로 읽기 작업과 쓰기 작업의 순서를 바꿀 수 있다는 사실이다. 다행히 읽기, 쓰기 작업의 순서를 바꾸는 기능이 있는 모든 프로세서에는 순차성을 보장하는 기계어 명령이 있다. 컴파일러가 특정 지역의 명령어를 재배치하지 못하도록 지정하는 것도 가능하다. 이런 명령을 배리어라고 한다.

    기본적으로, 프로세서가 다음과 같은 코드를 실행한다고 하면, a에 새 값을 저장하기 전에 b에 새 값을 저장하게 할 수 있다.

    a = 1;

    b = 1;

     

    컴파일러와 프로세서는 a와 b변수 사이에 아무런 관련성을 찾을 수 없다. 컴파일러는 컴파일 시에 재배치를 진행한다. 재배치는 정적으로 처리되며, 결과적으로 만들어진 오브젝트 코드는 a보다 먼저 b 값을 설정한다. 그러나 프로세서는 명령어를 읽도 처리하는 과정에서 연관성이 없는 명령들을 자신이 최적이라고 생각하는 순서대로 동적으로 재배치할 수 있다. a와 b 사이에 명백한 연관성이 없기 때문에, 거의 대부분의 경우 이런 재배치 작업으로 최적의 결과를 얻을 수 있다. 하지만 경우에 따라서는 개발자가 최적의 결과를 알고 있을 수도 있다.

    앞의 예에서는 재배치가 일어나지만, 다음과 같은 경우에는 a와 b 사이에 명백한 의존성이 있기 때문에 재배치 작업이 절대 일어나지 않는다.

    a = 1;

    b = a;

     

    하지만, 프로세서나 컴파일러는 다른 컨텍스트에 있는 코드의 내용을 알 수가 없다. 쓰기 작업이 의도한 순서대로 진행되는 것이 코드의 다른 부분이나 코드 외부에서 중요한 경우가 종종 있다. 이는 하드웨어 장치의 경우에 많이 발생하며, 다중 프로세서 장비에서도 ㄷ자주 벌어진다.

     

    rmb() 함수는 메모리 읽기 배리어를 제공한다. rmb() 함수 호출 전후로는 읽기 명령의 재배치가 일어나지 않는다. 즉 함수 호출 이전의 읽기 작업이 그 이후로 재배치되지 않으며, 함수 호춯 이후의 읽기 작업이 그 이전으로 재배치 되지 않는다. wmb() 함수는 메모리 쓰기 배리어를 제공한다. 이 함수는 읽기 작업이 아닌 쓰기 작업에 대해 rmb()와 같은 방식으로 동작한다. 쓰기 작업이 이 배리어를 넘나들 수 없다. mb() 함수는 읽기 및 쓰기 배리어를 제공한다. mb() 함수 호출 전후로는 읽기 작업 및 쓰기 작업의 재배치가 일어나지 않는다. 하나의 기계어 명령으로(rmb() 함수가 사용하는 것과 같은 명령인 경우가 많다.) 읽기 및 쓰기 배리어 효과를 얻을 수 있기 때문에 이 함수를 제공한다.

     

    rmb()의 변형으로 read_barrier_depends() 함수가 있는데, 이 함수는 이어지는 읽기 작업과 의존성이 있는 경우에만 읽기 배리어를 제공한다. 배리어 앞부분의 읽기 작업과 연관이 있는 배리어 뒷부분의 읽기 작업이 진행되기 전에 앞부분의 읽기 작업 완료를 보장하는 것이다. 기본적으로 rmb()와 비슷한 읽기 배리어라고 할 수 있지만, 배리어 전후로 연관성이 있는 읽기 작업에 대해서만 동작하는 것이다. 읽기 배리어가 필요 없는 일부 아키텍처는 noop 명령어를 이용해 read_barrier_depends() 함수가 구현되어 있기 때문에, rmb() 함수보다 훨씬 빠르게 동작한다. 이제 mb()와 rmb()를 사용하는 예를 살펴보자. a의 초기값은 1이고 b의 초기값은 2이다.

    스레드 1 스레드 2
    a = 3; -
    mb(); -
    b = 4; c = b;
    - rmb();
    - d = a;

    메모리 배리어가 없다면 d에는 a의 이전 값이 들어가고, c에는 새로운 b의 값이 들어가는 일이 발생할 수 있다. c값은 4이지만, d값은 1이 될 수 있다. mb() 함수를 사용하면 a와 b의 쓰기 작업을 순서대로 진행하게 되고, rmb() 함수를 통해 c와 d의 읽기 작업도 의도한 순서대로 진행하게 된다.

     

    최신 프로세서는 파이프라인 사용을 최적화하기 위해 순서를 바꿔서 명령어 해석 및 실행을 처리할 수 있기 때문에 이런 재배치 현상이 발생한다. b와 a의 일기 작업 순서가 바뀌면, 앞에서 이야기한 오류 상황이 발생할 수 있다. rmb() 함수와 wmb() 함수는 프로세서가 처리 중인 읽기 명령과 쓰기 명령을 마치라는 기계어 명령에 대응하는 함수다. 이제 rmb() 대신 read_barrier_depends()를 사용하는 예제를 보자. a의 초기값은 1이고, b의 초기값은 2이며, p는 &b, 즉 b의 포인터다.

     

    스레드 1 스레드 2
    a = 3; -
    mb(); -
    p = &a; pp = p;
    - read_barrier_depends();
    - b = *pp;

    여기서도 메모리 배리어가 없다면 pp값이 p로 설정되기 전에 b값을 pp로 설정하는 경우가 발생할 수 있다. 하지만 pp값을 읽는 작업은 p값을 읽는 작업과 연관돼있어서 read_barrier_depends() 함수만으로도 충분한 배리어를 설정할 수 있다. 물론 이곳에 rmb() 함수를 사용해도 되지만, 읽기 작업이 데이터에 의존적이므로 더 빠른 read_barrier_depends() 함수를 사용할 수 있다. 어떤 쪽을 사용하든, 왼편의 읽기 및 쓰기 작업의 순서를 보호하려면 mb() 함수를 사용해야 한다.

    smp_rmb(), smp_wmb(), smp_mb(), smp_read_barrier_depends() 매크로를 사용해 최적화가 가능하다. 이 매크로들은 다중 프로세서 커널에서는 일반적인 메모리 배리어로 정의되어 있으며, 단일 프로세서 커널에서는 컴파일러 배리어만으로 정의된다. 다중 프로세서 시스템에만 순차성 제한을 걸고 싶은 경우에 이런 SMP 매크로를 사용하면 된다.

    barrier() 함수를 사용하면 컴파일러가 이 함수 전후의 읽기 및 쓰기 명령을 최적화하는 것을 막을 수 있다. 컴파일러는 C 코드의 동작과 데이터의 의존 관계에 영향을 주지 않으면서 읽기 및 쓰기 명령을 재배치하는 방법을 알고 있다. 하지만 컴파일러는 현재 컨텍스트 밖에서 발생하는 사항들에 대한 정보는 알 수 없다. 현재 쓰기 작업을 하는 데이터를 인터럽트 핸들러에서 읽는다는 정보를 컴파일러가 파악할 수는 없다는 것이다. 이 때문에 읽기 작업을 하기 전에 모든 저장 작업을 마치도록 하는 기능이 필요할 수 있다. 앞에서 살펴본 메모리 배리어도 컴파일러 배리어 역할을 할 수 있지만, 컴파일러 배리어는 메모리 배리어에 비해 상당히 가볍다. 컴파일러 배리어는 컴파일러가 명령을 재배치할 가능성을 막는 것에 불과하기 때문에, 실질적으로 비용이 전혀 들지 않는다고 볼 수 있다.

    리눅스 커널이 지원하는 모든 아키텍처에서 사용할 수 있는 메모리 및 컴파일러 배리어 전체 함수

    배리어 설명
    rmb() 배리어 전후의 읽기 명령이 재배치되는 것을 예방한다.
    read_barrier_depends() 배리어 전후의 의존성이 있는 읽기 명령이 재배치되는 것을 예방한다.
    wmb() 배리어 전후의 쓰기 명령이 재배치되는 것을 예방한다.
    mb() 배리어 전후의 읽기 및 쓰기 명령이 재배치되는 것을 예방한다.
    smp_rmb() 다중 프로세서 장비에서는 rmb() 함수로, 단일 프로세서 장비에서는 barrier() 함수로 동작한다.
    smp_read_barrier_depends() 다중 프로세서 장비에서는 read_barrier_depends() 함수로, 단일 프로세서 장비에서는 barrier() 함수로 동작한다.
    smp_wmb() 다중 프로세서 장비에서는 wmb() 함수로 단일 프로세서 장비에서는 barrier() 함수로 동작한다.
    smp_mb() 다중 프로세서 장비에서는 mb() 함수로, 단일 프로세서 장비에서는 barrier() 함수로 동작한다.
    barrier() 배리어 전후의 읽기 및 쓰기 명령을 컴파일러가 최적화하는 것을 예방한다.

     

    배리어의 실제 효과는 아키텍처마다 다르게 나타날 수 있다. 예를 들어, 인텔 x86 프로세서처럼 읽기 명령의 재배치를 하지 않는 시스템의 wmb() 함수는 아무런 동작을 하지 않는다. 최악의 경우(즉, 명령의 순차성을 가장 지키지 않는 프로세서)를 가정하고 적절한 메모리 배리어를 사용하는 코드를 작성하면, 사용하는 아키텍처에 맞는 최적의 코드가 컴파일될 것이다.

     


    결론

    세마포어는 카운팅 세마포어, 바이너리 세마포어(뮤텍스), 리더-라이터 세마포어가 있다.

     

    카운팅 세마포어는 카운트 값이 있어 동시에 여러 스레드가 같은 락을 얻을 수 있다. 카운팅 세마포어를 사용할 경우에는 다수의 스레드가 동시에 같은 위험구역에 진입할 수 있으므로 상호 배제가 보장되지 않는다. 그보다는 특정 코드의 한계를 지정하는 데 사용한다. 카운팅 세마포어는 커널에서 그리 많이 사용되지 않는다.

     

    바이너리 세마포어는 뮤텍스라고 불리며, 커널에서 세마포어를 사용한다라고 하면 대부분 카운트가 1인 세마포어인 뮤텍스를 사용한다.

     

     

    댓글

Designed by Tistory.