POCU C언어 정주행 20회차 - Type-Generic, 정적 어서트, 메모리 정렬, 멀티 스레딩

2023. 1. 12. 17:10C언어 복습

1. Type-Generic

보통 Type-Generic이라고 하면 데이터의 자료형을 일반화하는 것을 의미한다. 다시 말해서 어떤 함수에 대해서 Type-Generic을 시킨다고 하면 해당 함수를 호출하는 것에 있어서 데이터 자료형과 상관없이 호출이 가능하게 되는 것이다. 그동안 C에는 이런 것이 불가능했지만 C11에서 매크로 함수를 이용해서 이런 것들을 지원할 수 있게 되었다. <tgmath.h>를 보면 컴파일러에서 Type-Generic으로 된 몇몇 수학 관련 함수를 제공한다. C11에 들어오면서 이런 몇몇 수학 함수들을 프로그래머가 직접 구현할 수 있게 된 것이다.

 

_Generic(<제어 키워드>, <연관 목록>)

 

위와 같이 사용하며 제네릭 선택이라고 부른다. switch문과 비슷한 생김새를 가지고 있는데 컴파일 도중에 어떤 자료형을 사용하는지를 결정하게 된다. 사용 방법은 아래와 같다.

#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.1415f;
    double num2 = 697429.9748;
    
    printf("ceil(%f) = %f\n", num1, ceil(num1));
    printf("ceil(%f) = %f\n", num2, ceil(num2));
    
    return 0;
}

ceil이라는 매크로 함수를 보면 각 자료형에 따라 ceill, ceil, ceilf 중 하나를 호출하는 코드라는 것을 알 수 있다. 더 정확히 말하면 각 자료형에 따라서 (X)앞에 올 키워드로 ceill, ceil, ceilf 중 하나를 고르는 것이다. 그러면 결과적으로 ceill(X), ceil(X), ceilf(X) 중 하나로 대체되는 것이고 결국 3개 중에 하나가 호출되는 결과가 나오는 것이다. 현재 long double, float이라는 자료형이 보이는데 default는 어떤 표현식이 들어왔을 때, 그 표현식의 데이터가 long double, float도 아닌 다른 것이라면 default에 있는 것으로 작동하는 방식이다. switch문과 매우 유사하다.

 

 

2. 정적 어서트

예외적인 상황을 처리하는 데 있어서 어서트라는 것을 많이 들어본 적이 있을 것이다. 기존의 어서트는 해당 부분이 실행될 때, 조건을 검사하고 그 조건이 거짓이면 프로그램 실행을 중단시킨다. 하지만 이 문제에는 단점이 존재했는데 실행이 되어야만 오류를 잡아낼 수 있다는 것이다. 그런데 잘 생각해보면 업계에서 만드는 프로그램의 규모는 굉장히 클 것이다. 근데 테스트를 하는 입장에서 몇몇 기능들은 실행을 시켜봤는데 사람이다보니 실수로 다른 기능들을 실행시켜 보는 것을 깜빡할 수 있는 것이다. 그런 실수를 잡을 수 있는 방법이 바로 정적 어서트이다. 이걸 쓰면 컴파일 오류를 띄워서 아예 실행 자체가 안되게 막아버리기 때문이다. 사용법은 아래와 같다.

// 이걸 인클루드하면 사용 가능!
#include <assert.h>

// 내부에 이런 것이 정의되어 있다.
#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");

여기서 기존의 어서트와 조금 다른 점이 있는데 기존의 어서트는 문자열을 매개 변수로 받지 않았다. 그런데 정적 어서트는 문자열을 매개 변수로 받는 것을 볼 수 있다. 그런데 참 이상한 것이 정적 어서트를 사용할 때, 굳이 문자열을 매개 변수로 넘겨주지 않으면 컴파일 오류를 띄운다는 것이다... 왜 이렇게 만들었는지는 모르겠다.

 

어서트와 정적 어서트의 베스트 프랙티스는 일단 가능하다면 정적 어서트를 사용하는 것이다. 정적 어서트에서 어떤 것을 비교하는 연산을 할 때, 컴파일 시간에 연산을 한다. 따라서 어떤 값의 비교를 하는데 그 값이 사용자의 입력과 같이 컴파일 시간에 알 수 없는 값을 비교하려고 하면 컴파일 오류를 띄운다. 그래서 이런 경우에는 동적 어서트를 사용한다.

 

 

3. 메모리 정렬

구조체를 다룰 때, 메모리 정렬에 대해서 배웠던 기억이 있는가? 전에 작성했던 글에서도 이에 대해서 다룬 적이 있었다. 

https://dafher-diary.tistory.com/12

 

POCU정주행 10회차 - 구조체 패딩, 2022-12-28 추가 내용

1. 구조체 패딩 보통 무언가를 배울 때 그것의 사용법에만 치중한 나머지 그것이 작동하는 기본 원리를 등한시하는 경우가 존재한다. 나도 그랬고 그래서 지금 복습을 통해 알게 된 내용을 이제

dafher-diary.tistory.com

 

구조체 패딩은 특정 바이트 수만큼 정렬이 이루어지는데 동적 할당을 할 때도 마찬가지로 플랫폼마다 특정 바이트 수만큼 정렬이 된다. 즉, 메모리를 할당하기 위해 적절한 주소를 찾을 때 그 주소는 특정 바이트 수의 배수라는 것이다. 그렇게 하는 것이 성능상 더 유리하기 때문이다. 또한 데스크탑이 아닌 다른 하드웨어에서는 직접 이것을 지정해줘야 하는 경우도 있다고 한다. 가장 대표적인 것이 그래픽 카드인데 얘는 정렬을 하지 않으면 아예 크래시를 내버리고 아예 동작을 하지 않는다. 그래픽 카드와 같이 정렬되지 않은 메모리를 하드웨어가 처리할 수 없는 경우에 이런 결과를 낸다고 한다.

 

그리고 C11에서는 이걸 지원해주는 함수가 나왔다.

alignment: 메모리 시작 주소가 정렬되어야 하는 바이트.

size: 할당할 바이트의 크기. 반드시 alignment의 배수여야 함.

void* aligned_alloc(size_t alignment, size_t size);
  • 반환값
    • 성공 시: 할당된 메모리 주소
    • 실패 시: 널 포인터
  • 실패 조건
    • alignment가 구현에서 유효하지 않거나 지원하지 않는 크기일 때
    • size가 alignment의 배수가 아닐 때
    • 단, 첫 C11 버전에서는 널 포인터 반환이 아님(결과가 정의되지 않음)
    • 수정 버전(DR 460)부터 널 포인터 반환
// 첫 C11 버전: 결과가 정의되지 않음, C11 수정 버전: 널 포인터
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);

 

위의 코드에서 aligned_alloc을 쓸 때, 정의되지 않은 결과가 나오는 부분을 보면 size의 크기를 너무 작게 집어넣은 것을 볼 수 있다. 이에 대한 해결책으로 align_up함수를 작성했다.

// 여러 값을 대입해서 계산하면 올림 처리를 하는 함수라는 것을 알 수 있다.
size_t align_up(const size_t alignment, const size_t size)
{
    return (size + alinment - 1) / alignment * alignment;
}

int main(void)
{
    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);
    
    return 0;
}

 

또한 지금까지는 동적 메모리를 할당할 때, 메모리 정렬을 시킨 것을 봤는데 스택에 메모리를 할당을 할 때에도 정렬이 가능하다. _Alinas키워드를 사용하는 방법이며 alignas로 매크로 함수로 정의되어 있다.

// 이것을 인클루드하면 _Alignas를 사용할 수 있고
// alignas라는 매크로 함수로 정의되어 있다.
#include <stdalign.h>

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);

뿐만 아니라 구조체의 멤버에도 적용할 수 있고 구조체 자체에도 적용시킬 수 있으며 _Alignof라는 함수를 이용해서 몇 바이트를 기준으로 정렬이 되어 있는지도 알 수 있다. 아래는 사용 예시이다.

// 이걸 인클루드해야 _Alignof 사용이 가능하다.
// alignof가 매크로 함수로 정의되어 있다.

int num1;
alignas(4096) int num2;
int num3;

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

// 실행 결과는 순서대로 4, 4096, 4가 나온다.

 

 

4. 멀티스레딩

이번 강의에서는 멀티 스레딩을 정말 수박 겉핥기로만 다루고 넘어갔다. 여기서 다룰 내용은 C11에 들어오면서 멀티스레딩과 관련된 함수들을 지원하게 되었는데 그것이 무엇인지 알아볼 것이다. 참고로 C11이전의 멀티스레딩은 운영체제의 몫이었다. 그래서 운영체제가 달라지면 멀티스레딩을 하는 데 있어서 포팅이 힘들었는데 이제 C11로 오면서 하나로 합치면서 포팅이 가능해진 것이다. 그럼 우선 멀티스레딩에 대해서 간단하게 알아보자.

 

멀티스레딩을 정말 간단하게 말하면 어떤 여러 개의 작업을 1개의 프로세스에서 동시에 실행시키는 것을 의미한다. 여기서 프로세스라는 것은 CPU, 메모리같은 자원을 할당받아서 어떤 작업을 처리하는 녀석을 말한다는 것을 다들 알고 있을 것이라고 생각한다. 이 때, 문제는 어떤 프로세스 내에 있는 스레드들이 동시에 실행된다는 것이다. 이러면 같은 자원에 거의 동시에 접근하는 레이스 컨디션(Race Condition)에 대한 문제가 생기게 된다. 그래서 예상치 못한 결과가 발생해서 매우 피곤하다.

 

이에 대한 해결책으로 lock을 거는 방법이 있다. 어떤 스레드가 어떤 자원에 접근을 하는 데 있어서 다른 스레드가 접근을 하지 못하게 만들기 위해 자신이 어떤 작업을 다 마칠 때까지 락을 걸어놓고 작업이 끝나면 그 락을 푸는 것이다. 근데 문제는 락을 거는 것 자체가 성능 저하로 이어진다는 것이다. 그래서 많은 CPU는 atomic이라는 연산을 지원한다. 단, 아래의 조건을 만족해야 한다.

  • 기본 데이터 형을 대상으로 해야 한다.
  • 간단한 연산에 한한다.

 

atomic

atomic이라는 단어는 원자의 라는 뜻을 가진 형용사이다. 즉, 어떤 것을 더 이상 원자 단위여서 쪼갤 수 없는 그런 개념으로 만드는 연산인 것이다.

출처: 네이버 사전

그렇다면 무엇을 대상으로 더 이상 쪼갤 수 없는 원자와 같은 개념으로 만든다는 것일까? 바로 CPU에서 처리해야 하는 어떤 연산을 대상으로 그런 처리를 하는 것이다. 이렇게만 말하면 이해가 힘들테니 아래의 코드를 보자.

int main(void)
{
    int i = 0;
    
    i = i + 1;
    
    return 0;
}

그냥 변수 하나를 선언해서 거기에 1을 증가시킨 후에 저장하는 아주 간단한 연산을 하고 있다. 그런데 i = i + 1; 과 같은 이런 간단한 연산도 사실 내부적으로 보면 아래의 그림과 같이 몇 개의 단계로 나뉘어져 있다.

 

출처: 포프 아카데미 C 언매니지드

그렇기 때문에 저게 만약 멀티스레드 환경이었다면 저 3개의 단계 중 어디에서 다른 스레드가 끼어들어서 잘못된 방향으로 값을 읽거나 변경시킬지 모르는 것이다. 여기서 저 3개의 단계에 해당하는 연산 자체를 atomic연산을 시키면 저 연산은 3개의 단계를 거쳐서 일어나는 연산이 아니라 저 3개의 단계 자체를 그냥 1개로 만들어버린다. 아래의 그림과 같이 말이다.

출처: 포프 아카데미 C 언매니지드

이러면 다른 스레드에서 저 연산이 끝날 때까지 더 이상 끼어들 틈이 없어지게 된다. 이게 C11부터 지원하는 atomic이라는 연산이다. 단, CPU에 따라서 C11 컴파일러를 쓴다고 해도 사용하지 못할 수 있으니 주의해야 한다. 사용법은 <stdatomic.h>를 인클루드한 후에 기본 자료형 앞에 _Atomic이라는 키워드를 붙이는 것이다. 더 자세한 기능들도 지원을 하지만 굳이 나열하지는 않으려고 한다.

 

_Thread_local

지금까지 멀티스레드에 대해서 간단하게 알아봤는데 다음과 같은 질문을 할 수 있다.

 

"아니, 그냥 스레드 별로 메모리 따로 쓰면 안되나?"

 

맞다! 그런데 강의에 따르면 사실 초기의 멀티스레드의 목적은 성능과 관계된 것이 아니라 어떤 일을 동시에 처리하는 것을 목표로 하고 있었고 그래서 락을 걸 때마다 성능이 저하되는 문제를 크게 신경쓰지 않았던 것 같았다라고 이야기를 했다. 여담으로 이렇게 얘기하면서도 스레드간에 공유하는 메모리가 하나도 없을 수는 없다고 했다. 단지 공유하지 않아도 되는 것을 분리하자고 이야기했다.

 

어쨋든 시간이 지나면서 스레드 별로 다른 메모리를 쓰기 위해서 그걸 프로그래머가 관리를 하려고 했었다. 그런데 C11부터 언어 자체에서 지원을 해주기 시작했다. 물론 표준은 아니고 선택 사항이다. 사용 방법은 아래와 같다. 이러면 각 스레드마다 s_num이라는 변수를 만들게 된다.

#include <threads.h>

static _Thread_local int s_num;
static thread_local int s_num;

물론 이것 말고도 더 다양한 스레드 관련된 함수와 매크로를 제공하지만 강의에서 자세하게 다루지는 않았다. 이 글에서도 그냥 넘어갈 예정이다. 이 포스팅에서 다루는 멀티스레드와 관련된 것은 C++ 언매니지드의 거의 마지막에 다루게 될 것 같다. C++ 언매니지드 강의 커리큘럼을 봤는데 거기서 강의 마지막 부분에서 스레드 프로그래밍을 했기 때문이다.

 

Noreturn

여담으로 noreturn이라는 것도 간단하게 짚으려고 한다. 물론 자세하게 정리할 생각은 없는 것이 강의에서 베스트 프랙티스라고 알려준 것이 잘 안쓴다는 이야기였기 때문이다... 그래도 멀티스레딩에서 아주 가끔씩은 쓴다고 한다. noreturn은 함수 선언에서 반환형 앞에 붙일 수 있는 키워드다. noreturn이라는 키워드를 붙이면 함수가 return을 해서 호출자로 다시 돌아가지 않는다는 뜻의 키워드다. 만약 이걸 붙여놓고 함수를 반환시키면 정의되지 않은 결과가 나온다.