본문 바로가기

프로그래머/C, C++

[포프 tv 복습] Type-Generic 함수 만들기, 정적 어서트, 메모리 정렬, 멀티스레딩

Type-Generic 함수 만들기, 정적 어서트, 메모리 정렬, 멀티스레딩


<tgmath.h>와 제네릭 선택

  • <tgmath.h>
    • 매개변수 형에 알맞는 수학 함수를 찾아서 호출해주는 매크로 함수
    • 컴파일러가 알아서 구현해 준 것
  • 프로그래머가 이런 매크로를 직접 만들 방법이 없었음
  • C11에서는 가능
    • 제네릭 선택(generic selection)이라 부룸
    • _Generic 키워드를 사용
    • 이제 <tgmath.h>도 이 키워드를 사용해서 직접 구현 가능

_Generic 키워드

_Generic(<제어 표현식>, <연관 목록>)
  • 컴파일 도중에 여러가지 표현식 중 하나를 선택하는 방법
    • 실행 중에 선택하는 것이 아님
  • 매크로 함수의 대체 목록으로 사용하는 게 일반적
  • 흡사 switch 문과 비슷해 보임

_Generic으로 ceil() 함수를 구현한 예

#include <stdio.h>
#include <math.h>

#define ceil(X) _Generic((x),       \
                long double: ceill, \
                default: ceil,      \
                float: ceilf)(X)

int main(void)
{
    float num1 = 3.1415;
    double num2 = 69798.8979;

    printf("ceil(%f) = %f\n", num1, ceil(num1));
    printf("ceil(%f) = %f\n", num2, ceil(num1));
}

연관 목록

_Generic(<제어 표현식>, <연관 목록>)
  • 연관 목록의 각 항목은 다음의 형태를 가짐

    <자료형>: <호출할 함수이름>   default: <호출할 함수이름>
  • 대체 규칙: 제어 표현식의 형에 따라 다음과 같이 대체

    1. 연관 목록에 그 형이 있으면 그에 대응하는 표현식으로 대체
    2. 연관 목록에 형이 없고 default: 가 있다면 그에 대응하는 표현식으로 대체
    3. default: 도 없다면 컴파일 되지 않음

어서트는 프로그래머의 베프

  • 코드 작성 시 프로그래머가 세운 가정 또는 선조건이 올바른지 검사
  • 개발 중에 실수를 일찍 발견할 수 있게 도와줌
  • 예: 구조체의 크기 보장하기
typedef struct status{
        unsigned int level;
        unsigned int exp;
} status_t;

assert(sizeof(status_t) == 8);
assert(sizeof(int) == 4);

어서트의 한계 - 플랫폼마다 특정 타입의 크기가 달라짐

아예 컴파일이 안 되게 하자!

  • 어떤 가정(ex. int 크기는 4)이 깨질 경우 아예 컴파일이 안 되면 됨
  • 그러려면 어서트 조건을 컴파일 중에 평가할 수 있어야 함
  • 하지만 실행 중에만 판단 가능한 조건들은 이런 게 불가능
  • 컴파일 중에 판단 가능한 조건들도 충분히 많다
    • 구조체 및 기본형의 크기 등
  • 이런 조건이 충족 안 할 때 컴파일을 막아주는 것이 정적 어서트

정적 어서트

_Static_assert(<표현식>, <메시지>)
  • <assert.h>를 인클루드 하면 static_assert가 정의되어 있음
#define static_assert _Static_assert

_Static_assert(sizeof(status_t) == 8, "status_t size mismatch");
// 혹은
static_assert(sizeof(status_t) == 8, "status_t size mismatch");
  • <표현식>이 0으로 평가되면 컴파일 오류
  • 무슨 문제인지 설명해주는 메시지도 '<메시지>'에 넣을 수 있음
  • 그런데 이건 안 됨...
static_assert(sizeof(status_t) == 8);
  • 추후 C 표준에서 어서트 조건만 받는 버전이 들어온다 함

베스트 프랙티스 : 어서트 vs 정적 어서트

  • 컴파일 중에 평가될 수 있는 조건이라면 무조건 정적 어서트
    • 컴파일이 안 되면 실행파일 자체가 안 나옴
    • 따라서 프로그래머가 어떻게든 고침
    • 단, C11에서만 사용 가능
  • 그 외의 조건은 어서트를 사용할 것
  • C11을 지원하지 않는 컴파일러를 사용한다면?
    • static_assert를 assert로 #define할 것
    • C11에서는 정적 어서트로 동작
    • 그 전 표준에서는 동적 어서트로 동작

_Noreturn 키워드

_Noreturn <반환형> <함수이름> (<매개변수>)
  • <stdnoreturn.h>를 인클루두 하면 noreturn을 대신 사용 가능
noreturn void cross_the_river()
{
    // 코드 생략
}
  • void는 이미 아무것도 반환하지 않겠다는 의미?
    • void는 반환하는 값이 없다는 의미
    • 여전힘 함수에서 원래 호출자로 돌아오긴(return) 함
  • noreturn은 그 함수에서 원래 호출자로 돌아가지 않는다는 의미
    • 멀티 스레딩을 할 때 필요한 경우 있음
    • 어떤 함수에서 프로그램을 종료시켜 버리는 경우도 해당

표준 라이브러리의 _Noreturn 함수들

  • 다음의 표준 라이브러리 함수들은 절대 호출자로 돌아오지 않음
    • abort()
    • exit()
    • _Exit()
    • quick_exit()
    • thrd_exit()
    • longjmp()
  • 대부분 무언가를 종료하는 함수들

베스트 프랙티스 : 별로 안 써요

  • 어짜피 이런 함수는 많지 않음
  • 따라서 noreturn을 쓸 곳이 별로 없을 것임
    • 그래서 최적화 때문에 noreturn을 붙인다는 것도 좀 오버
  • 그 대신 자체 문서화로서의 역할이 더 강하다고 볼 수 있음
    • 프로그래머가 함수 시그니처를 딱 보는 순간 판단할 수 있음
    • "이 함수는 절대 반환되지 않는구나!"

malloc()으로 할당한 메모리의 주소 예

int* int_p1 = malloc(sizeof(int));
char* char_p = malloc(sizeof(char));
int* inn_p2 = malloc(sizeof(int));

printf("int_p1: %p\n", (void*)int_p1);
printf("char_p: %p\n", (void*)char)p);
printf("int_p2: %p\n", (void*)int_p1);

free(int_p1);
free(char_p);
free(int_p2);
  • 모두 4의 배수가 출력됨

왜 4의 배수일까?

  • 표준은 이런 걸 정하지 않음
  • 데스크탑에서 실행하면 이런 패턴을 볼 수 있을 뿐
  • 이유?
    • 32비트에서는 4바이트 단위로 메모리를 접근하는 게 성능상 유리
    • 구조체에서 컴팡리러가 알아서 패딩을 붙이는 이유와도 비슷
  • 이렇게 시작 주소가 4의 배수로 나눠 떨어지는 메모리를 4바이트로 정렬된(aligned) 메모리라고 함

프로그래머가 직접 메모리를 정렬할 일이 있을까?

  • 하드웨어에 따라 직접 지정해줘야 하는 경우가 있음
  1. 특정 바이트로 정렬을 하면 성능 향상이 되는 경우
    • 정렬을 안 해도 여전히 동작은 함
    • 속도가 느려질 뿐
  2. 정렬을 하지 않으면 아예 동작하지 않는 경우
    • 정렬되지 않은 메모리를 하드웨어가 처리할 수 없는 경우가 이에 해당
      • 예: 그래픽 카드
    • 이 경우는 프로그램 실행 중에 크래시가 나는 경우가 대부분

aligned_alloc()

void* aligned_alloc(size_t alignment, size_t size);
  • alignment : 메모리 시작 주소가 정렬되어야 하는 바이트
  • size : 할당할 바이트의 크기. 반드시 alignment의 배수여야 함
  • 반환값
    • 성공 시 : 할당된 메모리 주소
    • 실패 시 : 널 포인터
  • 실패 조건
    1. alignment가 구현에서 유효하지 않거나 지원하지 않는 크기일 때
    2. size가 alignment의 배수가 아닐 때
      • 단, 첫 C11 버전에서는 널 포인터 반환이 아님(결과가 정의되지 않음)
      • 수정 버전(DR 460)부터 널 포인터 반환

aligned_alloc() 예

int* p1 = aligned_alloc(4096, sizeof(int));
printf("p1: %p\n", (void*)p1);

// 단, 하나의 정수형을 할당하더라도 정렬 크기(4096)의 배수를 유지해야 함
int* p2 = aligned_alloc(4096, 4096 * sizeof(int));
printf("p2: %p\n", (void*)p2);

free(p2);
free(p1);

// 올림 함수
size_t aligned_up(const size_t alignment, const size_t size)
{
    return (size + alignment -1) / alignment * alignment;
}

const size_t num_bytes = align_up(4096, sizeof(int));

int* p = aligned_alloc(4096, num_bytes);
printf("p : 0x%p\n", (void*)p);

free(p);

_Alignas 키워드

_Alignas(<표현식>)
  • 스택 메모리에 생기는 변수들 정렬
  • <stdalign.h>를 인클루드하면 alignas로 사용할 수 있음
int num1;
alignas(4096) int num2;
int num3;

printf("num1: %p\n", (void*)&num1);
printf("num2: %p\n", (void*)&num2);
printf("num3: %p\n", (void*)&num3);

구조체 정렬

  • 두 가지 방법이 있음
    • 각 멤버를 따로 정렬
    • 모든 멤버를 일괄적으로 같은 크기로 정렬
  • 구조체 변수를 선언할 때, 그 변수의 시작 주소도 정렬 가능

구조체 정렬 예1 : 멤버 변수 별 정렬

typedef struct data{
    alignas(4096) int num;          // int가 4바이트 차지하니, 실제로는 1024 차지
    alignas(1024) int dummy[10];    // data 주소로부터 1024 띈 부분에 할당
} data_t;

data_t data = {0, };

printf("data size: %d, alignof: %d\n", sizeof(data), _Alignof(data));
printf("data.num: %p\n", (void*)&data.num);
printf("data.dummy: %p\n", (void*)&data.dummy);

구조체 정렬 예2 : 모든 멤버 변수 동일 정렬

typedef struct data{
    int num;
    int dummy[10];
} data_t;

typedef struct aligned_data{
    int num;                // 여기도 4096으로 잡힘
    alignas(4096) int dummy[10];
} aligned_data_t;

구조체 변수 선언 시 메모리 정렬

typedef struct data {
    int num;
    int dummy[10];
} data_t;

alignas(4096) data_t data;

printf("data size: %d, alignof: %d\n", sizeof(data), _Alignof(data));   // 44, 4096
printf("data: %p\n", (void*)&data);

동적 할당을 할 경우엔 주의하자

  • malloc() 함수는 구조체용 메모리를 할당한다는 사실을 모름
    • 따라서 구조체 멤버변수의 정렬에 맞게 메모리 할당이 안 됨
  • 그 대신 aligned_alloc()을 사용해야 함
    • 메모리 크기도 반드시 배수가 되어야 한다는 점 잊지 말 것
    • 아까 봤던 aligned_up() 함수 같은 걸 만들어서 쓸 것
typedef struct data {
    int num;
    alignas(4096) int dumy[10];
} data_t;

const size_t alignment = _Alignof(data_t);
const size_t size = align_up(alignment, sizeof(data_t));

data_t* p = aligned_alloc(alignment, size);

printf("data: %p\n", (void*)p);

free(p); 

변수가 몇 바이트 정렬인지 아는 법

  • 가장 간단한 방법은 나머지 연산을 하는 것
boos is_aligned(const void* const p, const size_t align)
{
    return ((unsigned int)p % align) == 0;
}

_Alignof()

_Alignof(<자료형>)
  • <stdalign.h>를 인클루드 하면 alignof()로 사용 가능
int num1;
alignas(4096) int num2;
int num3;

printf("align of num1 = %d\n", _Alignof(num1)); // 4
printf("align of num2 = %d\n", alignof(num2));  // 4096
printf("align of num3 = %d\n", alignof(num3));  // 4

멀티 스레딩

  • C11 이전에는 멀티 스레딩을 지원하지 않음
  • 언어 자체에서 지원하지 않음. 운영체제의 몫이었음

멀티 스레딩을 감독하는 건 운영체제

  • 여러 스레드가 프로그램을 나눠 실행하려면 감독이 있어야 함
  • 그 감독 겸 지휘자가 운영체제

C11 이전의 멀티 스레딩은 OS 함수를 사용

  • 각 운영체제 별로 스레드를 관리하는 함수가 있었음
  • C 프로그래머는 각 운영체제가 제공하는 함수들을 호출했을 뿐
    • 즉, 어쩔 수 없이 포팅이 불가능한 코드를 작성
    • 플랫폼마다 #ifdef를 써서 처리했던 게 일반적

C11 이전의 멀티 스레딩은 운영체제가 담당

  • 그것을 하나로 합쳐서 C11 표준으로 만든 게 전부
    • 따라서 이제 포팅이 가능한 코드 작성이 가능

멀티 스레드 환경에서 가장 큰 문제 중 하나

  • 여러 스레드가 동일한 메모리에 접근하는 것(Race Condition)
  • 해결법 : 어떤 스레드가 접근하는 동안 다른 스레드의 접근을 막음
  • 이를 보통 락(lock)을 건다고 함
  • 이 때 사용하는 대표적인 락의 종류가 바로 뮤텍스(mutex)

그러나 락을 잠그고 여는 행위는 너무 느리다

  • 따라서, 많은 CPU는 락을 안 써도 되는 atomic 연산을 지원
    • 락을 안 써도 멀티 스레드 환경에 안전
    • 단, 기본 데이터 형이어야만 함(ex. int)
    • 간단한 연산에 한함(ex. 변수값 1 증가하기)

atomic의 의미

  • 어떤 연산을 더 이상 쪼갤 수 없는 하나의 단위로 보는 것
  • 메모리 읽기(read) - 데이터 갱신(update) - 메모리 저장(store)
    • 이 자체를 하나의 단위로 보겠다는 의미
    • 즉, 이 자체가 하나의 연산이 됨
  • atomic 연산 동안에는 다른 스레드가 개입할 여지가 없음
    • 3단계 모델에서는 중간에 개입해서 서로 값을 덮어쓰는 경우가 생김
  • C11은 CPU의 atomic 명령어들을 프로그래머에게 노출해줌
    • 변수 선언 앞에 붙일 수 있는 _Atomic 키워드
    • atomic 연산에 사용할 수 있는 함수들

_Atomic

_Atomic <자료형>
  • STDC_NO_ATOMICS이 정의되어 있으면 지원 안 함
    • 뒤에 볼 함수들도 마찬가지
  • <stdatomic.h>에 키워드랑 형을 미리 합쳐 정의해좋은 것도 있음
    • atomic_bool, atomic_char ... (총 37종)

기본형 변수를 atomic으로 선언하기

// _Atomic 키워드를 사용할 때
static _Atomic int s_num_threads;
static _Atomic _Bool s_exiting;

// 편의성 매크로를 사용할 때
static atomic_int s_num_threads;
static atomic_bool s_exiting;
  • 이렇게 선언만 해놓으면 일반 변수 사용하듯이 사용하면 됨
  • _Atomic을 안 붙이면 ++연산자를 사용하더라도 3단계로 동작

atomic 함수들

  • 다음과 같은 함수들이 있음
    • atomic_init()
    • atomic_flag_test_and_set()
    • atomic_load()
    • atomic_store()
    • ...

CPU가 atomic을 지원 안 할 경우

  • 여전히 atomic 키워드와 함수를 사용할 수도 있음
    • STCD_NO_ATOMICS이 정의되어 있지 않는 한
  • C에서 내부적으로 락을 대신 사용할 수도 있음
  • 그건 다음의 매크로들을 확인하면 됨
    • ATOMIC_BOOL_LOCK_FREE
    • ATOMIC_CHAR_LOCK_FREE
    • ATOMIC_INT_LOCK_FREE
    • ATOMIC_LLONG_LOCK_FREE
    • etc.
  • 0 : 지원 안 함, 1 : 지원하지만 가끔 atomic이 아님, 2 : 언제나 atomic

_Thread_local

_Thread_local <자료형>
  • STCD_NO_THREADS이 정의되어 있으면 지원 안 함
  • <threads.h>를 인클루드 하면 thread_local을 쓸 수 있음
  • 줄여서 흔히 TLS(thread local storage)라고 부름
  • atomic 변수와 마찬가지로 자료형 앞에 붙이는 키워드
  • 그러면 각 스레드마다 이 변수의 사본이 생기는 꼴!
static _Thread_local int s_num;
static thread_local int s_num;

스레드 지원 라이브러리: <threads.h>

  • 스레드 관리용 함수 및 자료형을 제공
  • 선택적 구현 사항
    • 스레드 관련 코드를 작성할 때, STDC_NO_THREADS를 확인할 것

스레드 관련 함수들

  • thrd_create() : 스레드 만들기
  • thrd_join() : 스레드 실행이 끝나는 것을 기다리기
  • thrd_yield() / thrd_sleep() : 실행 순서 양보하기
  • 뮤텍스와 조건 변수 이용하기
    • mtx_init() / mtx_destory()
    • mtx_lock() / mtx_trylock() / mtx_unlock()
    • cnd_init() / cnd_destory()
    • cnd_signal() / cnd_broadcast() / cnd_wait()
  • etc...