READ_ONCE() 및 WRITE_ONCE()와 lockless 리스트

<kernel v5.10>

list_head 구조체를 사용하는 환형 양방향 연결 리스트를 다루는 함수 내부에서 어느 순간(v4.5 부터) WRITE_ONCE() 매크로가 사용된다. 그 중 INIT_LIST_HEAD()에 왜 WRITE_ONCE() 함수를 추가하였는지 알아본다.

  • A: 리스트의 사용 전후로 lock/unlock을 사용하는 SMP 시스템은 WRITE_ONCE()를 사용하지 않아도 무방하다. 그런데 이 양방향 연결 리스트를 lockless로 운영하는 SMP 시스템의 경우 같은 리스트를 공유하여 접근하는 경쟁(contention) 상황에서 WRITE_ONCE() 매크로가 필요해졌다.
    • READ_ONCE() 및 WRITE_ONCE()
      • 접근 하려는 영역(메모리 및 IO 가상 주소)에 원하는 자료 타입 만큼의 값이 분할되어 읽거나 기록되지 않는 조치가 필요하다.
        • Case) 주로 32비트 시스템 등에서 8바이트 포인터등을 다룰 때 4 바이트 단위로 2 번 기록하지 않게한다.

 

먼저 READ_ONCE() 및 WRITE_ONCE()를 알아본 후 SMP core 들간에 경합이 일어나는 상황에서 lockess로 운영되는 리스트와 관련된 코드 위주로 살펴본다. (분석 케이스로 본문에서는 list_del_init() 함수를 살펴본다.)

 


READ_ONCE() & WRITE_ONCE()

READ_ONCE()

include/asm-generic/rwonce.h

#define READ_ONCE(x)                                                    \
({                                                                      \
        compiletime_assert_rwonce_type(x);                              \
        __READ_ONCE(x);                                                 \
})

인자 @x의 타입 사이즈가 1, 2, 4 또는 8 바이트에 해당하고, @x 주소에서 타입 사이즈 만큼의 값을 atomic하게 읽어온다.

  • 아래 __READ_ONCE()에 추가적인 설명을 하였다.

 

compiletime_assert_rwonce_type()

include/asm-generic/rwonce.h

/*
 * Yes, this permits 64-bit accesses on 32-bit architectures. These will
 * actually be atomic in some cases (namely Armv7 + LPAE), but for others we
 * rely on the access being split into 2x32-bit accesses for a 32-bit quantity
 * (e.g. a virtual address) and a strong prevailing wind.
 */
#define compiletime_assert_rwonce_type(t)                                       \
        compiletime_assert(__native_word(t) || sizeof(t) == sizeof(long long),  \
                "Unsupported access size for {READ,WRITE}_ONCE().")

타입 @t의 사이즈가 1, 2, 4 또는 8 바이트인 경우가 아닌 경우 컴파일 타임에 에러를 발생시킨다.

 

include/linux/compiler_types.h

/* Is this type a native word size -- useful for atomic operations */
#define __native_word(t) \
        (sizeof(t) == sizeof(char) || sizeof(t) == sizeof(short) || \
         sizeof(t) == sizeof(int) || sizeof(t) == sizeof(long))

타입 @t의 사이즈가 1, 2, 4 또는 long(4 또는 8) 타입에 해당하는 바이트인 경우인지 여부를 반환한다.

 

__READ_ONCE()

include/asm-generic/rwonce.h

/*
 * Use __READ_ONCE() instead of READ_ONCE() if you do not require any
 * atomicity. Note that this may result in tears!
 */
#ifndef __READ_ONCE
#define __READ_ONCE(x)  (*(const volatile __unqual_scalar_typeof(x) *)&(x))
#endif

인자 @x 주소로부터 @x 타입에 해당하는 사이즈만큼의 값을 atomic하게 읽어온다.

  • 컴파일러의 재배치(optimization) 기능을 사용하지 말고, 반드시 해당 주소 @x로부터 값을 읽어오도록 생략하지 않게 컴파일하여 코드를 생성한다.
  • 컴파일러가 두 번에 나눠 읽지 않고, 또한 다른 값과 같이 읽지 않고 정확히 요청 타입의 길이에 맞춰 atomic하게 한번에 읽어오도록 컴파일하여 코드를 생성한다.
    • ARM32에서도 long long 타입의 8바이트 값을 읽을 때 4 바이트 값을 읽는 ldr 명령을 사용하지 않고, 8바이트 값을 두 개의 레지스터로 읽는 ldrd 명령을 사용한다.
  • 스칼라 타입은 char, int, long 등과 같이 하나의 값만을 가지는 데이터 타입이다. (vs 벡터 타입)

 

예) 다음은 ARM32 시스템에서 long long 타입의 값을 READ_ONCE()로 읽어들인 예이다.

  • ldrd 명령 한번에 8바이트 값을 2 개의 32bit 레지스터에 읽어옮을 알 수 있다.
.       long long a = 10;
   1042c:       e3a0200a        mov     r2, #10
   10430:       e3a03000        mov     r3, #0
   10434:       e14b21fc        strd    r2, [fp, #-28]  ; 0xffffffe4
        long long *p = &a;
   10438:       e24b301c        sub     r3, fp, #28
   1043c:       e50b3008        str     r3, [fp, #-8]
        long long b;

        b = READ_ONCE(*p);
   10440:       e51b3008        ldr     r3, [fp, #-8]
   10444:       e1c320d0        ldrd    r2, [r3]
   10448:       e14b21f4        strd    r2, [fp, #-20]  ; 0xffffffec

 

예) 다음은 ARM64 시스템에서 long long 타입의 값을 READ_ONCE()로 읽어들인 예이다.

  • ARM32와 다르게 ldr 명령 한번으로 8바이트 값을 1 개의 64bit 레지스터에 읽어옮을 알 수 있다.
.       long long a = 10;
 810:   d2800140        mov     x0, #10
 814:   f9000ba0        str     x0, [x29, #16]
        long long *ap = &a;
 818:   910043a0        add     x0, x29, #0x10
 81c:   f9000fa0        str     x0, [x29, #24]
        long long b;

        b = READ_ONCE(*ap);
 820:   f9400fa0        ldr     x0, [x29, #24]
 824:   f9400000        ldr     x0, [x0]
 828:   f90013a0        str     x0, [x29, #32]

 

__unqual_scalar_typeof()

include/linux/compiler_types.h

/*
 * __unqual_scalar_typeof(x) - Declare an unqualified scalar type, leaving
 *                             non-scalar types unchanged.
 */
#define __unqual_scalar_typeof(x) typeof(                               \
                _Generic((x),                                           \
                         char:  (char)0,                                \
                         __scalar_type_to_expr_cases(char),             \
                         __scalar_type_to_expr_cases(short),            \
                         __scalar_type_to_expr_cases(int),              \
                         __scalar_type_to_expr_cases(long),             \
                         __scalar_type_to_expr_cases(long long),        \
                         default: (x)))

non-scalar 타입으로 주어진 인자 @type의 자료 타입에 따라 다음 중 하나의 타입으로 반환한다. (_Generic() 키워드는 다음 절에서 설명한다)

  • signed char
  • unsigned char
  • signed int
  • unsigned int
  • signed long
  • unsigned long
  • signed long long
  • unsigned long long
  • 기타 타입

 

참고: compiler_types.h: Optimize __unqual_scalar_typeof compilation time (2020, v5.8-rc1)

 

WRITE_ONCE()

include/asm-generic/rwonce.h

컴파일러 베리어 volatile을 포함한 WRITE_ONCE() 매크로의 주요 기능은 다음과 같다.

#define WRITE_ONCE(x, val)                                              \
do {                                                                    \
        compiletime_assert_rwonce_type(x);                              \
        __WRITE_ONCE(x, val);                                           \
} while (0)

인자 @x의 타입 사이즈가 1, 2, 4 또는 8 바이트에 해당하고, @x 주소에 @x 타입에 해당하는 사이즈만큼의 @val값을 atomic하게 기록한다.

  • 아래 __WRITE_ONCE()에 추가적인 설명을 하였다.

 

__WRITE_ONCE()

include/asm-generic/rwonce.h

#define __WRITE_ONCE(x, val)                                            \
do {                                                                    \
        *(volatile typeof(x) *)&(x) = (val);                            \
} while (0)

인자 @x 주소에 @x 타입에 해당하는 사이즈만큼 val 값을 atomic하게 기록한다.

  • 컴파일러의 재배치(optimization) 기능을 사용하지 말고, 반드시 해당 주소 @x에 @val값을 기록하도록 생략하지 않게 컴파일하여 코드를 생성한다.
  • 컴파일러가 두 번에 나눠 기록하지 않고, 또한 다른 값과 같이 기록하지 않고 정확히 요청 타입의 길이에 맞춰 atomic하게 한번에 기록하도록 컴파일하여 코드를 생성한다.
    • ARM32에서도 long long 타입의 8바이트 값을 기록할 때 4 바이트 값을 기록하는 str 명령을 사용하지 않고, 8바이트 값을 두 개의 레지스터로 기록하는 strd 명령을 사용한다.

 


_Generic() keyword

/*
 * Prefer C11 _Generic for better compile-times and simpler code. Note: 'char'
 * is not type-compatible with 'signed char', and we define a separate case.
 */

C11 표준을 따르는 컴파일러에 추가된 새 키워드로 인자 하나의 데이터 타입을 기준으로 함수의 컴파일 타임 오버로딩을 지원한다.

 

__scalar_type_to_expr_cases()

include/linux/compiler_types.h

#define __scalar_type_to_expr_cases(type)                               \
                unsigned type:  (unsigned type)0,                       \
                signed type:    (signed type)0

인자 @type의 부호 여부에 따라 한쌍식의 0 값을 반환한다.

  • 이는 _Generic()에서 사용된다.

 


연결 리스트

리눅스 커널에서 자주 사용되는 두 가지 연결 리스트를 알아본다.

  • 환형 양방향 연결 리스트(A Circular Doubly Linked List)
    • 엔트리 노드의 추가/삭제를 수행하고, 삽입(insert)등은 하지 않는 단순한 연결 리스트이다. 리스트의 접합/분리/회전에 강점이 있다.
    • list_head 구조체 하나만을 사용하여 리스트 헤드와 리스트 노드 엔트리를 표현한다.
    • list_*로 시작하는 함수들 (본문에서 사용하는 리스트 함수)
      • list_add(), list_add_tail(), list_del()
  • 양방향 연결 리스트(A Doubly Linked List)
    • 엔트리 노드의 추가/삭제/삽입(insert)을 수행하는 리눅스 커널의 대표적인 연결 리스트이다.
    • hlist_headhlist_node 구조체를 각각 사용하여 리스트 헤드와 리스트 노드 엔트리를 표현한다.
    • hlist_*로 시작하는 함수들
      • hlist_add_head(), hlist_add_before(), hlist_behind(), hlist_del()
  • 단방형 연결 리스트(A Singly Linked List)
    • 엔트리 노드의 추가/삭제를 수행하는 단방향 연결 리스트로 일부 조건에 따라 완전한 lockless로 사용된다.
      • 예) 공급자로 llist_add()를 사용하고, 소비자로 llist_del_all()을 사용하는 조합 등이다.
    • llist_headllist_node 구조체를 각각 사용하여 리스트 헤드와 리스트 노드 엔트리를 표현한다.
    • llist_*로 시작하는 함수들
      • llist_add(), llist_del_first(), llist_del_all()

 

다음 그림은 세 가지 연결 리스트들의 실제 구조체 내부의 연결 상태를 비교하여 보여준다.

 


환형 양방향 연결 리스트(A Circular Doubly Linked Lists)

list_del_init()

include/linux/list.h

/**
 * list_del_init - deletes entry from list and reinitialize it.
 * @entry: the element to delete from the list.
 */
static inline void list_del_init(struct list_head *entry)
{
        __list_del_entry(entry);
        INIT_LIST_HEAD(entry);
}

리스트에서 인자로 요청한 @entry를 삭제하고 엔트리를 초기화한다.

  • list_del_init() 함수는 리스트 엔트리를 하나 삭제하고 삭제한 엔트리를 초기화하기 위해 내부에서 INIT_LIST_HEAD()를 호출한다.

 

다음 그림과 같이 list_del_init() 함수는 __list_del_entry() 함수와 INIT_LIST_HEAD() 함수를 차례대로 호출한다.

 

__list_del_entry()

include/linux/list.h

static inline void __list_del_entry(struct list_head *entry)
{
        if (!__list_del_entry_valid(entry))
                return;

        __list_del(entry->prev, entry->next);
}

리스트에서 인자로 요청한 @entry를 삭제한다.

  • 코드 라인 3~4에서 CONFIG_DEBUG_LIST 커널 옵션이 사용되는 경우 __list_del_entry_valid() 함수는 poison 기록 여부를 살펴보고 두 번 삭제되는 등을 알아내어 경고 메시지를 출력하고 false를 반환한다. 해당 커널 옵션을 사용하지 않는 경우 항상 true를 반환한다.
  • 코드 라인 6에서 리스트에서 인자로 요청한 @entry를 삭제한다.

 

__list_del()

include/linux/list.h

/*
 * Delete a list entry by making the prev/next entries
 * point to each other.
 *
 * This is only for internal list manipulation where we know
 * the prev/next entries already!
 */
static inline void __list_del(struct list_head * prev, struct list_head * next)
{
        next->prev = prev;
        WRITE_ONCE(prev->next, next);
}

리스트에서 인자로 요청한 @entry를 삭제한다.

 

INIT_LIST_HEAD()

include/linux/list.h

/**
 * INIT_LIST_HEAD - Initialize a list_head structure
 * @list: list_head structure to be initialized.
 *
 * Initializes the list_head to point to itself.  If it is a list header,
 * the result is an empty list.
 */
static inline void INIT_LIST_HEAD(struct list_head *list)
{
        WRITE_ONCE(list->next, list);
        list->prev = list;
}

@list를 초기화한다.

  • 환형 연결 리스트의 경우 next와 prev가 자기 자신을 가리키게 하는 것으로 초기화한다.

 


lockless 연결 리스트

lockless 환형 양방향 연결 리스트와 WRITE_ONCE()

본문에서 READ_ONCE()와 WRITE_ONCE()를 먼저 알아보았다. 이제 위에서 살펴본 list_del_init() 함수를 모두 인라인으로 연결하여 분석해본다.

 

inline 처리한 list_del_init()

static inline void list_del_init(struct list_head *entry)
{
        /* 현재 엔트리를 제거하고 뒷쪽 및 앞쪽 엔트리 연결을 갱신 */
        entry->next->prev = entry->prev;                        // (1)
        WRITE_ONCE(entry->prev->next) = entry->next;            // (2)

        /* 엔트리의 next, prev 모두 자신을 가리키게 한다 */
        WRITE_ONCE(entry->next, entry);                         // (3)
        entry->prev = entry;                                    // (4)
}

 

다음 그림은 세 개의 cpu에서 동시에 같은 리스트에 접근하고 있다. 그 중 하나의 cpu가 엔트리를 삭제할 때 다른 두 cpu와 경합(contention)이 발생하는 모습을 보여준다. lockless 방식으로 리스트를 사용하는 경우 리스트 엔트리의 포인터 엔트리인 next가 atomic하게 한 번에 교체되어야 한다. (믈론 lockless 환경이 아닌 경우에는 WRITE_ONCE를 사용하지 않아도 상관 없다)

 

참고

 

2 thoughts to “READ_ONCE() 및 WRITE_ONCE()와 lockless 리스트”

  1. 안녕하세요 16기 양원혁입니다.
    READ_ONCE와 WRITE_ONCE에 관련한 디테일한 설명들 정말 잘 읽었습니다!

    다만, 하단부의 “lockless 리스트와 WRITE_ONCE()” 에 나오는 예제는 물음표가 생깁니다.

    그림의 상황은 lock 없이 consumer가 2명인 케이스로 해석하였습니다.
    제 생각에는 이런 상황에서 WRITE_ONCE만으로는 데이터의 일관성이 유지되지 않을 것 같습니다.

    CPU:0 CPU:1
    B->next->prev = B->prev
    A->next->prev = A->prev
    위와 같이 실행이 된다면, WRITE_ONCE와 관련없이 해당 리스트의 링크 구조는 깨져버리지 않을까요?

    그렇다면, “WRITE_ONCE는 왜 사용되었느냐?”라는 질문에 저도 올려주신 커밋을 읽으며 나름대로 생각을 해봤습니다.

    1) list: Use WRITE_ONCE() when initializing list_head structures (2015, v4.5-rc1)
    2) list: Use READ_ONCE() when testing for empty lists (2015, v4.5-rc1)
    3) list: Use WRITE_ONCE() when adding to lists and hlists (2015, v4.5-rc1)

    위 커밋들의 내용에 따르면 list_empty 함수는 lock없이 사용되는 경우가 있는데, head의 next 링크가 부분적으로
    load/store되면 문제가 되므로, atomic하게 읽고 저장하는 것으로 변경한 것 같습니다.

    4) rculist: Use WRITE_ONCE() when deleting from reader-visible list (2015, v4.5-rc1)

    위 커밋에서는 list_del_rcu()에서도 next에 대한 링크가 atomic하게 업데이트 하려고 처리한 것 같습니다.

    1. 단방향 리스트 조차도 완벽하게 조건 없이 lockless로 운영을 할 수 없고, 조건에 따라 lockless를 운영할 수 있습니다.
      그런데 하물며 양방향 리스트의 경우는 더욱 그러합니다. 그러고 보니 양원혁님 의견이 맞는것 같습니다.

      lockless로 양방향 리스트에서 multiple producers가 삭제를 하면 안될 듯 합니다.
      샘플을 바꿔서 하나의 공급자가 삭제를 하고, multiple consumers 형태의 그림으로 바꿔야 하겠네요.

      좋은 의견 주셔서 감사합니다.

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다