ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [리눅스 커널] Symmetric Multi-Processing(SMP)
    Linux/kernel 2023. 8. 3. 02:32

    글의 참고

    - https://linux-kernel-labs.github.io/refs/heads/master/lectures/smp.html

    - https://en.wikipedia.org/wiki/Symmetric_multiprocessing

    - https://we.riseup.net/riseup+tech/balancing-hardware-interrupts

    - https://www.kernel.org/doc/Documentation/preempt-locking.txt

    - https://archive.kernel.org/oldlinux/htmldocs/kernel-hacking/routines-processorids.html

    - https://www.kernel.org/doc/Documentation/preempt-locking.txt

    - http://books.gigatux.nl/mirror/kerneldevelopment/0672327201/ch09lev1sec9.html

    - http://books.gigatux.nl/mirror/kerneldevelopment/0672327201/ch04lev1sec3.html

    - https://stackoverflow.com/questions/59112033/linux-disable-irq-and-local-irq-save


    글의 전제

    - 내가 글을 쓰다가 궁금한 점은 파란색 볼드체로 표현했다. 나도 모르기 때문에 나중에 알아봐야 할 내용이라는 뜻이다.
    - 밑줄로 작성된 글은 좀 더 긴 설명이 필요해서 친 것이다. 그러므로, 밑 줄 처친 글이 이해가 안간다면 링크를 따라서 관련 내용을 공부하자.


    글의 내용

    Symmetric multi-processing(https://en.wikipedia.org/wiki/Symmetric_multiprocessing)

    • Symmetric multi-processing 혹은 Shared-memory multi-processing(SMP)를 말로 설명하기 어렵다. 특징들로 SMP가 뭔지를 알아보는것이 가장 쉽다. SMP의 특징은 다음과 같다.
      • 멀티프로세서 컴퓨터 하드웨어 및 소프트웨어 아키텍처를 말한다.
      • 두 개 이상의 동일한 프로세서가 하나의 공유 메인 메모리에 연결있다.
      • 모든 프로세서들이 단일 운영 체제에 의해 제어된다.
    • 잘 보면 프로세스가 아닌 프로세서임을 유의하자. 오늘날 대부분의 멀티 프로세서 시스템은 SMP 아키텍처를 사용한다. 멀티 코어 프로세서의 경우는, SMP 아키텍처는 코어들에게 적용되며 코어를 별도의 프로세서로 취급한다.

     

    Symmetric multi-processing(https://linux-kernel-labs.github.io/refs/heads/master/lectures/smp.html)

    - Lecture objectives

    - Synchronization basics

    • Race conditions은 아래의 두 조건이 동시에 발생했을 때, 발생할 수 있다.
      1. 최소 2개의 실행 컨텍스트가 병렬로 실행되고 있다.
        1. truly run in parallel (e.g. two system calls running on different processors)
        2. one of the contexts can arbitrary preempt the other (e.g. an interrupt preempts a system call)
      2. 실행 컨텍스트들이 동일한 메모리의 위치에 read-write 액세스를 시도한다.
    • Race conditions can lead to erroneous results that are hard to debug, because they manifest only when the execution contexts are scheduled on the CPU cores in a very specific order.
    • 아래의 예시는 counter 값에 대한 release 동작을 잘못 구현하여 발생한 race condition의 예시다.
    void release_resource()
    {
         counter--;
    
         if (!counter)
             free_resource();
    }
    • A resource counter is used to keep a shared resource available until the last user releases it but the above implementation has a race condition that can cause freeing the resource twice:

     

    출처 - https://linux-kernel-labs.github.io/refs/heads/master/lectures/smp.html

     

    • 위의 예시를 설명하면 다음과 같다.
      • Thread A가 먼저 CPU에 대한 사용권을 얻어, release_resource() 함수를 호출한다. 최초 counter값은 2다.
      • Thread A에서 counter값을 1 감소시킨다.
      • 그런데 여기서 Thread B가 CPU를 선점해버린다. Thread B도 release_resource() 함수를 호출한다. counter값이 0이 된다.
      • 그리고 Thraed B는 counter가 0이므로, free_resource() 함수를 호출한다.
      • Thread B가 일을 모두 마쳤으므로, CPU를 반납한다. Thread A가 계속 일을 진행한다.
      • counter 값이 0이므로, Thread A 또한 free_resource() 함수를 호출한다.
    • 위의 예시의 문제는 release_resource() 함수는 해당 리소스에 대해 딱 한 번만 호출되야 하는 함수라는 것이다. 그러나 위의 예시는 release_resource()가 2번 호출된다.
    • 위와 같은 race condition을 피하기 위해서는, 개발자들은 반드시 제일 처음에 race condition이 발생하는 critical section부터 찾아 내야한다. Critical section이란 parallel context환경에서 shared memory에 read/write가 동시에 발생하는 구간을 의미한다. 
    • In the example above, the minimal critical section is starting with the counter decrement and ending with checking the counter's value.
    • 일단 Critical section을 찾았으면, race condition은 피할 수 있다. race condition을 피하는 기법은 아래와 같다.
      1. make the critical section atomic (e.g. use atomic instructions)
      2. disable preemption during the critical section (e.g. disable interrupts, bottom-half handlers, or thread preemption)
      3. serialize the access to the critical section (e.g. use spin locks or mutexes to allow only one context or thread in the critical section)

     
     

    - Linux kernel concurrency sources

    • There are multiple source of concurrency in the Linux kernel that depend on the kernel configuration as well as the type of system it runs on:
      • 싱글 코어 시스템 + non-preemptive kernel
        • 현재 실행중인 프로세스가 선점되는 경우는 인터럽트가 발생했을 때 밖에 없다.  
      • 싱글 코어 시스템 + preemptive kernel
        • 현재 실행중인 프로세스는 인터럽트가 발생했을 때 선점될 수 있다.
        • 현재 실행중인 프로세스는 다른 프로세스에 의해서 선점될 수 있다.
      • 멀티 코어 시스템
        • 현재 실행중인 프로세스는 인터럽트가 발생했을 때 선점될 수 있다.
        • 현재 실행중인 프로세스는 다른 프로세스에 의해서 선점될 수 있다.
        • 멀티 코어 시스템에서는 현재 프로세스는 다른 프로세스와 병렬로 실행될 수 있다. 그리고 다른 프로세스에서 실행되는 인터럽트와 함께 실행도 될 수 있다.

     

    - Atomic operations

    • 특정 상황에서, atomic operations 들을 사용해서 race conditions을 피할 수 있다. atomic 이라고 되어있으면 이건 하드웨어 차원에서 지원해주는 기능이다. 내가 생각하기에 race condition을 피하기 위한 가장 확실한 방법인 것도 같다. 절대 아니다... 아래 atomic operations의 문제가 나온다.. 리눅스에서 각 벤더사들에게 atomic operations들에 대해 표준 API를 제공하도록 하고 있다. 그래서 OEM 개발자들은 어떤 CPU를 쓰더라도 아래의 표준 함수를 통해서 atomic operations들을 사용할 수 있다.
      • integer based
        • simple - atomic_inc(), atomic_dec(), atomic_add(), atomic_sub()
        • conditional - atomic_dec_and_test(), atomic_sub_and_test()
      • bit based
        • simple - test_bit(), set_bit(), change_bit()
        • conditional - test_and_set_bit(), test_and_clear_bit(), test_and_change_bit()
    • 위의 release_resource 예제를 atomic operation을 사용해서 아래와 같이 변경할 수 있다. 
    void release_resource()
    {
        if (atomic_dec_and_test(&counter))
             free_resource();
    }

     

    • 그러나 멀티 코어 시스템에서 atomic operations은 한 가지 문제가 있다. 그건 바로 core-level에서는 atomic operation이 보장되지만, system-level에서는 atomic operation이 더 이상 보장되지 않는다는 것이다.
    • 위의 말을 이해하려면 atomic operation이 어떻게 memory에 load & store하는지 알아봐야 한다. Then we can construct race condition scenarios where the load and store operations are interleaved across CPUs, like in the example below where incrementing a value from two processors will produce an unexpected result:

     

    출처 - https://linux-kernel-labs.github.io/refs/heads/master/lectures/smp.html

     

    • 위의 예시의 문제점이 뭘까? 위의 예는 멀티 프로세스 환경이다. 심지어 atomic operation을 통해서 선점되지 않는 것을 보장해준다. 문제가 뭘까? 이제부터 내 추측이다. atomic operation은 메모리를 atomic 하는게 아니고 연산을 atomic 하게 한다. 즉, 이 말은 CPU에서 수행하는 기능은 atomic 하지만, memory는 atomic 하지 않다는 것이다. 위에서 말한 system은 메모리부터해서 여러가지 주변장치들 모두를 포함하는 것으로 보인다. CPU0과 CPU1은 동일한 `Inc v`라는 연산을 가지고 있다. 이 연산은 당연히 atomic operation이다. 위의 환경이 멀티 프로세서이기 때문에 CPU0과 CPU1이 거의 동시에 Memory에서 v의 값을 가져온다. 즉, 0값을 가져온다. 그리고 동시에 그 값에 +1을 한다. 그리고 Memory에 다시 store한다. 메모리안에 있는 v의 값은 1이 되버린다. 위의 시나리오에서 원했던 것은 v가 2가 되는 것이었는데, v가 1이네? 결과만 말하면 현대 SMP 시스템에서 atomic operations들은 위와 같은 식으로 구현되어 있지 않다. 왜? atomic이라는 말 자체에 `실행 컨텍스트를 보장해야 한다` 라는 의미가 담겨있기 때문에, 위의 예제같이 구현되어 있지 않다. 훨씬 복잡하게 구현되어 있다. 아래의 예제를 보자. 

     

    • SMP 시스템에서 atomic operations을 제대로 제공하려면, 복잡한 여러 가지 기술들이 합져서 만들어진다. 예를 들어, 인텔의 x86의 LOCK_OPERATION() 와 같은 함수들이 존재한다. 역할은 다음과 같다.
      • 먼저 시스템 버스를 LOCK을 건다.
      • OPERATION을 수행한다.
      • 시스템 버스를 UNLOCK 한다. 

    출처 - https://linux-kernel-labs.github.io/refs/heads/master/lectures/smp.html

     

    • ARM사의 LDREX와 STREX 명령어를 사용하면 atomic access를 보장할 수 있다. LDREX를 통해 메모리에서 값을 load한다. 그리고 exclusive monitor에게 atomic operation을 수행중이라고 알린다.  The STREX attempts to store a new value but only succeeds if the exclusive monitor has not detected other exclusive operations. 결국 atomic operations을 제대로 구현하려면, 개발자들은 exclusive monitor가 성공이라고 할 때까지, atomic operation(LDREX and STREX 두개 연산 모두)를 계속 재시도 해야 한다. 근데 버스에 LOCK을 걸었으니, 다른 프로세서에서 접근을 못하지 않나? CPU 마다 메모리로 연결되는 버스가 각각 달려있나? exclusive monitor가 왜 필요한지를 모르겠네...
    • atomic operations이 굉장히 가벼우면서 효율이 좋은 동기화 메커니즘으로 알고있지만, 디테일한 동작원리를 보면 굉장히 복잡하게 구현되어 있다는 거을 알 수 있다. 결론적으로 atomic operations는 비싼 연산에 속한다는 것이다. 
    • 아쉬운건 많은 사람들이 atomic operations이 spnning 이나 context swtiches도 없고, 하드웨어 레벨에서 지원하는 연산이기 때문에 굉장히 효율적인 연산이라고 착각을 많이 하는것 같다..  

     
     

    - Disabling preemption (interrupts)

    • 비선점형-싱글 코어 시스템에서 동시성 관련 문제를 야기할 놈은 인터럽트밖에 없다. 뭔말이냐? 코어가 하나면 병렬로 프로세스가 실행될 수는 없으면서 한 순간에 하나의 프로세스만 실행된다. 비선점형이니 하나의 프로세스가 실행중일 때, 다른 프로세스가 선점도 못한다. 그럼 이제 남은 놈은? 인터럽트밖에 없다. 현재 실행 중인 프로세스와 인터럽트 핸들러가 처리하는 코드에 공유 메모리가 있다면, 동시성 문제가 발생할 수 있다. 비선점형-싱글 코어 시스템에서는 동시성 문제를 막으려면 인터럽트를 비활성화 시키는 건 필수적이다.  
    • 리눅스에서 인터럽트를 disable/enable 하는 표준 인터페이스를 제공한다.
    #define local_irq_disable() \
         asm volatile („cli” : : : „memory”)
    
    #define local_irq_enable() \
        asm volatile („sti” : : : „memory”)
    
    #define local_irq_save(flags) \
        asm volatile ("pushf ; pop %0" :"=g" (flags)
                      : /* no input */: "memory") \
        asm volatile("cli": : :"memory")
    
    #define local_irq_restore(flags) \
        asm volatile ("push %0 ; popf"
                      : /* no output */
                      : "g" (flags) :"memory", "cc");
    • 인터럽트들을 명시적으로 아래의 함수들을 사용해서 enable/disable 할 수 있다.
      • local_irq_disable()
      • local_irq_enable()
    • 그러나 these APIs should only be used when the current state and interrupts is known. They are usually used in core kernel code (like interrupt handling). 결국 local_irq_disable(), local_irq_enable()은 사용하기가 상당히 껄끄럽다는 소리다. 
    • 결국 인터럽트 때문에 발생하는 동시성 문제를 피하고 싶다면, 일반적으로 아래의 함수들을 사용한다.
      • local_irq_save()
      • local_irq_restore()
    • 요 놈들은 

     
     

    - Spin Locks

    • 위에서 race condition을 피할 수 있는 3가지 방법을 얘기했다. 그중에 3번째에 `serialize the access to the critical section`가 있었다. spin locks은 크리티컬 섹션에 serialize access(spin locks, mutexes)를 사용한다. 멀티 코어 시스템에서 진짜 제대로 동작하는 병렬형 프로그램을 동작시키려면 결국 serialize access 방식이 필수적이다. spin lock 코드는 아래와 같이 심플하게 구현할 수 있다.
    spin_lock:
        lock bts [my_lock], 0
        jc spin_lock
    
    /* critical section */
    
    spin_unlock:
        mov [my_lock], 0

     
     

    - Process and Interrupt Context Synchronization

    • 2개의 실행 컨텍스트, 즉, 프로세스 컨텍스트와 인터럽트 컨텍스트가 동일한 메모리에 접근하는 시나리오는 굉장히 흔한일이다. 요 시나리오는 2가지 시스템 관점에서 볼 수 있다.
      • 싱글 코어 시스템 - 인터럽트를 비활성화하면 작업을 진행하면 된다.
      • 멀티 코어 시스템 - 2개의 CPU[A,B]가 있다고 치자. CPU[A]에서 프로세스가 열심히 일을 하는중이다. 그런데 CPU가 하나가 있으니 CPU[B]에서는 인터럽트 컨텍스트가 실행될 수 있다. 즉, 2개의 컨텍스트가 병렬로 실행되는 셈이다. 여기서는 인터럽트를 비활성화 시킨다고 될 일이 아니다.
    • 멀티 프로세서용으로 만들어진 spin lock을 활용하면 이 문제를 가볍게 해결될 것 같지만 그렇지 않다. 심지어 spin lock을 쓰는 순간 deadlock이 발생시킬 수도 있다. 아래 예시를 보자.
      • 프로세스 컨텍스트에서 spin lock을 취한다.
      • 인터럽트가 발생했다. 근데 해당 인터럽트가 동일 CPU로 선점(스케줄링)되었다.
      • 인터럽트 핸들러가 동작한다. 그리고 spin lock을 취한다.
      • 해당 CPU는 deadlock 상태가 된다.
    • 위의 상황을 각 컨텍스트 관점에서 피할 수 있는 방법이 있다. 
      • In process context - 인터럽트를 disable하고 spin lock을 얻는다. 이렇게 하면 interrupt 뿐만 아니라 다른 CPU 코어들과 race conditions을 피할 수 있다. 왜? 인터럽트를 disable 했으니, 인터럽트에 대한 걱정은 버리고, spin lock을 걸면 busy-waiting 이기 때문에 다른 CPU 코어에게 선점되지 않을테니 race condition도 발생하지 않겠지. 위의 2개의 기능을 합친 API가 바로 spin_lock_irqsave()와 spin_lock_restore()이다.
      • In interrupt context - take a spin lock; this will will protect against race conditions with other interrupt handlers or process context running on different processors
    • We have the same issue for other interrupt context handlers such as softirqs, tasklets or timers and while disabling interrupts might work, it is recommended to use dedicated APIs:
      • In process context use spin_lock_bh() (which combines local_bh_disable() and spin_lock()) and spin_unlock_bh() (which combines spin_unlock() and local_bh_enable())
      • In bottom half context use: spin_lock() and spin_unlock() (or spin_lock_irqsave() and spin_lock_irqrestore() if sharing data with interrupt handlers)
    • 앞서 `Linux kernel concurrency sources`에서 언급했듯이, 동시성(concurrency) 문제를 만드는 여러 가지 원인(인터럽트, 프로세스, 프로세서)들이 있다. 선점형이면 다른 프로세스 때문에 동시성 문제가 발생할 수 있다.  

     
     

    • Preemption is configurable  - 만약 Preemption이 활성화되면 latency와 response time이 더 좋아지고, 반면에 Preemption이 비활성화되면 throughput이 증가한다. 선점형 운영체제는 시분할 방식을 사용하여 아주 짧은 시간 단위로 여러 프로세스들을 실행해서 실시간 반응성을 제공하는 운영체제를 말한다. 일반적으로 선점형 OS가 비선점형 OS보다 사용자들에게 더 빠른 반응속도를 보여준다는 글은 이미 구글에 널려있다. 비선점형이 처리량(throughput)이 좋다는 것은 처음 알았다. 대신 비선점형은 반응 속도가 좋지 않아, 사용자에게 버벅되는 느낌을 준다고 한다. 
    • Preemption은 spin locks과 mutexes에 의해 disabled 되지만, 리눅스 커널 코드를 통해서 수동으로 disabled 시킬 수 있다.
    • As for local interrupt enabling and disabling APIs, the bottom half and preemption APIs allows them to be used in overlapping critical sections. A counter is used to track the state of bottom half and preemption. In fact the same counter is used, with different increment values:

    출처 - https://linux-kernel-labs.github.io/refs/heads/master/lectures/smp.html

     

    - Process Affiniity

    : 리눅스에서는 프로세스가 생성되면, 해당 프로세스는 특정 CPU 에서만 동작되도록 한다(`affinity`). 왜냐면, `캐쉬` 때문이다. `프로세스 1`이 A 프로세서에서 생성됬다. `프로세스 1`의 메모리가 A 프로세서의 L1, L2 캐쉬에 로드됬다. 이 때, `프로세스 1`이 디스크 요청 관련 업무가 생겨 잠시 블락 상태가 됬다. 일정 시간 후에, 디스크 작업이 마무리되서 `프로세스 1`을 깨우려고 한다. B 프로세서가 놀고 있어서, B 프로세서에게 `프로세스 1`을 할당한다. `프로세스 1`의 메모리를 B 프로세서 캐쉬에 로드한다. 앞에 상황은 `프로세스 1`이 여러 프로세서에서 사용되면서, 계속 새로운 캐쉬에 로드되기 때문에 성능에 문제를 가져올 수 있다. 그래서 앞에서 언급했던 대로 프로세스를 특정 프로세서에게 종속해서 실행하도록 한다.

     

     

    - Volatile [ 참고1 참고2 참고3 참고4 ]

    : `volatile` 키워드는 근본적으로 메모리에서 데이터를 직접 가져와야 하는 상황에 사용하는 키워드다. 한 가지 명심해야 하는 건, 이 키워드가 컴파일러의 모든 최적화를 이루어 주지는 않는다는 것이다.

    It is often thought that the volatile keyword provides a safety net that stops the compiler from optimizing code. Unfortunately, this is not true. The volatile keyword determines that the compiler needs to reload data from memory when it needs to use it and should store it back to memory as soon as it is modified. It does not stop the com-piler from performing optimizations around the data nor does it form any kind of pro-tection against data races.
     
    - 참고 : https://www.brainkart.com/article/Reordering-of-Operations-by-the-Compiler_9516/

     

    : 메모리에서 데이터를 직접 가져와야 하는 상황이 뭐가 있을까?

    MMIO"
    - 주변 장치와 CPU는 RAM을 두고 통신하기 때문에, 반드시 RAM에 직접 접근해야 한다.

    1" Multi-Task
    - 멀티 프로세서에서 각 프로세서가 자신의 캐시에만 데이터를 `읽고/쓰기` 하면, 동시성 문제가 발생할 수 있다.

    : 컴파일러의 최적화를 2가지 측면에서 바라봐야 한다. 여기서 `volatile`은 Memory Synchronization 문제를 완화해 줄 수 있다.

    Memory Synchronization Optimization"
    - MMIO, Shared Memory in Multi-task

    Memory Re-ordering Opmitization"
    - Memory Barrier(Memory Fence)

    : `Atomic`과 `Volatile`의 차이가 뭘까? 2개의 키워드는 비교되어서 나올 때는, 주로 다수의 프로세스가 Shared Memory에 접근할 때를 기준으로 설명한다. `Atomic`은 인터럽트와 다른 프로세스에 의한 선점이 되지 않는 것을 보장한다. 즉, 안전하게 공유 메모리에 데이터를 읽고/쓰기가 가능하다. 당연히 이 때 캐쉬에 내용을 수정하는 것은 의미가 없다. 반드시 공유 메모리에 데이터를 수정해야 한다. `Volatile`은 위에서 말한 것처럼 메모리의 데이터를 리로딩해야 하는 시점에 사용하는 키워드다. 둘은 아예 다른 키워드다. `Atomic` 키워드는 `Volatile` 키워드를 품고 있다고 보는 것이 맞다.

     

     

     

    - SMP API

    : smp_processor_id

    " `smp_processor_id` 함수는 현재 해당 코드를 실행하고 있는 CPU 코어 번호를 반환한다. 내부적으로 `raw_smp_processor_id` 매크로 함수를 호출하는데, 이 함수는 아키텍처마다 독립적으로 존재하는 함수다. 즉, 구현 방식이 아키텍처마다 다르다. `arm64` 같은 경우는 CPU마다 `cpu_number` 라는 전역 변수에 CPU 번호를 할당하는 것으로 확인된다.

    // arch/arm64/include/asm/smp.h - v6.5
    DECLARE_PER_CPU_READ_MOSTLY(int, cpu_number);
    
    /*
     * We don't use this_cpu_read(cpu_number) as that has implicit writes to
     * preempt_count, and associated (compiler) barriers, that we'd like to avoid
     * the expense of. If we're preemptible, the value can be stale at use anyway.
     * And we can't use this_cpu_ptr() either, as that winds up recursing back
     * here under CONFIG_DEBUG_PREEMPT=y.
     */
    #define raw_smp_processor_id() (*raw_cpu_ptr(&cpu_number))
    
    ...
    // include/linux/smp.h - v6.5
    /**
     * raw_processor_id() - get the current (unstable) CPU id
     *
     * For then you know what you are doing and need an unstable
     * CPU id.
     */
    
    /**
     * smp_processor_id() - get the current (stable) CPU id
     *
     * This is the normal accessor to the CPU id and should be used
     * whenever possible.
     *
     * The CPU id is stable when:
     *
     *  - IRQs are disabled;
     *  - preemption is disabled;
     *  - the task is CPU affine.
     *
     * When CONFIG_DEBUG_PREEMPT; we verify these assumption and WARN
     * when smp_processor_id() is used when the CPU id is not stable.
     */
    
    /*
     * Allow the architecture to differentiate between a stable and unstable read.
     * For example, x86 uses an IRQ-safe asm-volatile read for the unstable but a
     * regular asm read for the stable.
     */
    #ifndef __smp_processor_id
    #define __smp_processor_id(x) raw_smp_processor_id(x)
    #endif
    
    #ifdef CONFIG_DEBUG_PREEMPT
      extern unsigned int debug_smp_processor_id(void);
    # define smp_processor_id() debug_smp_processor_id()
    #else
    # define smp_processor_id() __smp_processor_id()
    #endif

     

    : preempt_disable & preempt_enable [ 참고1 ]

    " `preempt_xxxxx` 관련 함수들이 모두 프로세스와 관련이 깊다. 예를 들어, 리눅스 커널에서는 공유 자원에 대한 락이나 인터럽트 disabled를 통해 프로세스가 선점을 되는 것을 막는다. 그런데, 공유 자원에 대한 락이 없고 인터럽트 disabled도 아닌 상황에서 프로세스를 선점시키지 않고 싶을 때가 있다. 이럴 때, `preempt_disable` 함수를 호출한다. 이 함수는 자신을 호출한 프로세스의 구조체(struct thread_info)안에 `preempt_count`값을 `1` 증가시킨다(아키텍처마다 `preempt_count` 필드가 선언된 위치는 다를 수 있다). 이 값이 `0`이면, 해당 프로세스는 스케줄러에 의해 `선점`당하지 않는다. 그리고, 양수면 스케줄러에 의해 해당 프로세스는 선점당 할 수 가 있다(`preempt_enable` 함수 사용).

    // arch/arm64/include/asm/preempt.h - v6.5
    static inline void __preempt_count_add(int val)
    {
    	u32 pc = READ_ONCE(current_thread_info()->preempt.count);
    	pc += val;
    	WRITE_ONCE(current_thread_info()->preempt.count, pc);
    }
    
    static inline void __preempt_count_sub(int val)
    {
    	u32 pc = READ_ONCE(current_thread_info()->preempt.count);
    	pc -= val;
    	WRITE_ONCE(current_thread_info()->preempt.count, pc);
    }
    ....
    
    // include/linux/preempt.h - v6.5
    #define preempt_count_add(val)	__preempt_count_add(val)
    #define preempt_count_sub(val)	__preempt_count_sub(val)
    ....
    
    #define preempt_count_inc() preempt_count_add(1)
    #define preempt_count_dec() preempt_count_sub(1)
    ....
    
    #define preempt_disable() \
    do { \
    	preempt_count_inc(); \
    	barrier(); \
    } while (0)
    
    #define preemptible()	(preempt_count() == 0 && !irqs_disabled())
    
    ....
    #define preempt_enable() \
    do { \
    	barrier(); \
    	preempt_count_dec(); \
    } while (0)

    : `arm64`에서는 `__preempt_count_add`와 `__preempt_count_sub` 함수를 통해 `preempt.count` 값을 감소시키는 것을 알 수 있다.

     

     

    : get_cpu & put_cpu [ 참고1 참고2 ]

    " 현대의 SMP를 지원하는 시스템들은 주로 CPU 별 데이터를 자주 사용한다. 대게 CPU별 데이터는 `배열`에 저장된다. 예를 들어, 배열의 인덱스를 `CPU 번호`로 해서 CPU 별 데이터를 구별하는 것이다. 아래 코드는 CPU 별 데이터가 명확하기 때문에 LOCK이 필요가 없다. 그런데, `선점`이 등장하면서부터 문제가 발생한다.

    // http://books.gigatux.nl/mirror/kerneldevelopment/0672327201/ch11lev1sec10.html
    unsigned long my_percpu[NR_CPUS];
    
    int cpu;
    
    cpu = smp_processor_id();   /* get current processor */
    my_percpu[cpu]++;  /* ... or whatever */
    printk("my_percpu on cpu=%d is %lu\n", cpu, my_percpu[cpu]);

    : 첫 번째로, `프로세스 A`가 프로세서[0]에서 실행되고 있었다. 그런데, `프로세스 A`가 `cpu = smp_processor_id()` 까지만 실행하고 스케줄러에 의해 선점당해서 나중에 프로세서[1]에서 다시 실행이 되었다. 그런데 문제가 생겼다. `프로세스 A`가 다시 실행하는 코드에서 `cpu` 변수의 값은 이전 프로세서 번호인 `0`이다. 즉, 현재 실행되고 있는 프로세서[1]과 번호가 매칭되지 않는 것이다.

     

    : 두 번째로, 프로세서[0]는 동일한 환경에서 프로세스 A, B가 `my_percpu[cpu]` 변수를 가지고 `race condition` 상황이 발생할 수 있다. 위에 코드에서 `my_percpu[0]`의 초기값이 `0`이라면, 프로세스 A, B가 경쟁하는 관계에서 두 프로세스 모두 printk 함수에 my_percpu[cpu] 값을 `2`로 출력할 가능성도 있다. 

     

    : 위의 2개의 상황 모두 프로세스를 선점하는 것이 문제다. 즉, 프로세스 자체를 선점하지 못하도록 할 방법이 필요하다. 이 때, 필요한 것이 위에서 언급한 `preempt_disable` 함수다. 이 함수와 `smp_processor_id` 함수를 합친것이 `get_cpu` 함수다.

    // include/linux/smp.h - v6.5
    #define get_cpu()		({ preempt_disable(); __smp_processor_id(); })
    #define put_cpu()		preempt_enable()

     

     

    : need_resched & set_tsk_need_resched & clear_tsk_need_resched [ 참고1 참고2 ]

    " 리눅스 커널 스케줄러는 `schedule` 함수를 명시적으로 호출하면 해당 시점에 현재 프로세스를 선점하여 런큐에 첫 번째 프로세스를 실행시킨다. 그런데, 매 번 명시적 `schedule` 함수를 호출하는 것은 모든 개발자들에게 운영체제의 깊은 지식을 요구하는 셈이다. 그래서, 특정 조건이 만족했을 때 자동으로 프로세스 스케줄링이 동작하도록 해야 한다. 특정 조건에는 타이머 인터럽트, 디스크 인터럽트 등이 있을 수 있다. 그런데, 앞에 말한 이벤트들은 외부 인터럽트에 의한 비동기적 스케줄링이다. 즉, 동기적으로 현재 코드 흐름을 유지하면서 프로세스 스케줄링이 자동으로 되기 위해서 `xxxxx_need_resched` 함수 패밀리들이 등장했다. 리눅스 커널 스케줄러는 `need_reshced` 함수를 통해서 언제 프로세스 스케줄링을 해야할 지를 결정한다.

    // include/linux/sched.h -v6.5
    
    static inline void set_tsk_need_resched(struct task_struct *tsk)
    {
    	set_tsk_thread_flag(tsk,TIF_NEED_RESCHED);
    }
    
    static inline void clear_tsk_need_resched(struct task_struct *tsk)
    {
    	clear_tsk_thread_flag(tsk,TIF_NEED_RESCHED);
    }
    
    static __always_inline bool need_resched(void)
    {
    	return unlikely(tif_need_resched());
    }
    
    ...
    ...
    
    // include/linux/thread_info.h -v6.5
    static __always_inline bool tif_need_resched(void)
    {
    	return test_bit(TIF_NEED_RESCHED,
    			(unsigned long *)(&current_thread_info()->flags));
    }

    : 위의 구조를 보면 알 수 있겠지만, 리눅스 커널 스케줄러는 `struct thread_info->flags`가 `TIF_NEED_RESCHED`가 SET 되어있으면 프로세스 스케줄링을 한다. 즉, 선점당해야 하는 프로세스에게 `TIF_NEED_RESCHED` 플래그를 SET 하는 것이다. 이제 이 프로세스는 필요없으므로, 런큐에서 다른 프로세스를 실행하자의 의미를 가지고 있다. 프로세스가 자신에게 할당된 모든 타임 슬라이스를 소모하면 타이머 인터럽트에 의해 호출되는`scheduler_tick` 함수에 의해서 해당 프로세스는 `TIF_NEED_RESCHED` 플래그가 SET 된다. 그리고, `try_to_wake_up` 함수가 호출되는 시점에 현재 실행되고 있는 프로세스보다 해당 함수의 인자로 전달된 프로세스가 우선순위가 더 높으면 wakeup 시키고, 현재 프로세스를 선점시켜야 하므로, `TIF_NEED_RESCHED` 플래그를 SET 한다.

     

    : 그리고, 시스템 콜 호출시 유저 레벨로 복귀할 때도 `need_rechsed` 함수를 통해 스케줄링 여부를 파악한다. 또, 인터럽트 핸들러가 처리를 완료하고 이전 루틴으로 복귀하려고 할 때, `need_rechsed` 함수를 통해 스케줄링 여부를 파악한다. 대표적으로 위의 4가지 경우에 스케줄링 여부를 파악한다.

Designed by Tistory.