포스트

시스템 프로그래밍 13. Time

시스템 프로그래밍 13. Time

건국대학교 시스템 프로그래밍 진현욱 교수님의 수업을 정리한 내용입니다.

Time Type

  1. Wall Time (or Real Time)
  2. Monotonic Time
  3. Process Time

컴퓨터에서 정의하는 시간의 종류는 총 세가지가 있다. Wall Time은 우리의 현실 시간을 의미한다. 사용자가 설정을 통해 Wall Time을 조절할 수 있다. Monotonic Time은 시스템이 부팅된 이후 측정되는 시간이며, 시간 간격이 정확하고 엄격하다. 사용자는 Monotonic Time을 조정할 수 없다. 보통 차이를 통해 상대적인 계산에 많이 사용한다. Process Time은 Process가 실제로 CPU 자원을 할당받아 실행되는 시간을 의미한다. Procoess가 Block된 상태일 땐 시간이 흐르지 않는다. 때문에 Process Time은 다른 시간에 비해 느리게 흐른다고 느껴질 수 있다.

Hardware Clocks

  1. Real Time Clock (RTC)
  2. High Precision Event Timer (HPET)
  3. Time Stamp Counter (TSC)

시간을 재기 위해 하드웨어에 실제 시계를 삽입하여 시간을 잰다. RTC는 Real time을 재기 위한 시계이며, 컴퓨터가 꺼져도 시간이 흐른다는 특징이 있다. HPET는 Real Time보다 Resolution가 더 높다. 즉, RTC는 ms까지 잴 수 있지만 HPET는 ns 단위까지 시간을 잴 수 있다. 하지만 컴퓨터가 꺼지면 시간이 업데이트 되지 않는다는 점에서 RTC를 대체할 수 없다. HPET는 설정된 특정 시간 간격마다 시스템에게 Interrupts를 보내어 시스템의 타이머를 업데이트해준다. 마지막으로 TCS는 CPU 내의 레지스터의 값을 계속 증가시켜서 시계처럼 사용하는 카운터다. RTC, HPET보다 Resolution가 더 높다. 하지만 주기적인 Insterrupts를 발생시킬 수 없다는 점에서 HPET를 대체할 수 없다.

System Clock

Hardware Clocks는 주기적으로 Interrupts를 보내서 이 System Clock을 업데이트한다. 이 주기를 tick 또는 jiffy라고 부르며, 업데이트되는 카운터를 jiffies counter라고 부른다. 하지만 Hardware Clock가 주기적으로 보내는 Interrupts는 그저 jiffies counter를 증가시키는 목적만 있는 것이 아니다. 커널은 기본적으로 Passive하게 작동한다. 다른 프로세스에서 시스템 콜을 호출할 때 커널이 깨어나는데, 이때 시스템 콜만 처리하는게 아니라 밀려있던 다른 작업도 호출된 김에 처리하게 된다. 만약 시스템 콜이 호출되지 않으면 작업을 영영 처리할 수 없게 된다. 따라서 커널은 주기적으로 작업을 처리하는 과정을 가지고 있어야 한다. 이 주기를 Hardware Clock를 통해 얻어낸다. Clock는 주기적으로 시스템에게 인터럽트를 주고, 시스템은 인터럽트를 감지할 때마다 커널의 밀려있던 작업을 처리한다. 그렇기 때문에 하드웨어 시계의 인터럽트를 너무 작게 설정하면 CPU가 버벅이고, 너무 크게 설정하면 오버헤드가 발생하기 때문에 적당한 값으로 설정해야 한다. 이를 실험을 통해 1초당 250번 인터럽트를 발생시키는 것이 적당하다고 알려져 있으며, 보통 HZ를 250으로 설정한다고 표현한다. 초당 250번이므로, 4ms당 한번 인터럽트가 발생한다.

Unix Clocks

  • int gettimeofday(struct timeval *tv, struct timezone *tz)
  • int settimeofday(const struct timeval *tv, const struct timezone *tz)
1
2
3
4
struct timeval { 
    time_t tv_sec; /* seconds */ 
    suseconds_t tv_usec; /* microseconds */
};

Unix 계열의 System Call은 Real Time만 구할 수 있다. gettimeofday()를 사용하면 현재 시간을 얻어내고, settimeofday()를 사용하면 Real Time을 재설정할 수 있다. 둘다 성공하면 0, 실패하면 -1을 반환한다.

timeval 구조체에는 초와 마이크로 초가 담긴다. tv_sec는 기준 시간 1970년 1월 1일 00:00:00:00 시간부터 경과한 초를 의미한다. signed int를 사용하여 시간을 저장하는데, 문제가 있다. 초가 21억을 넘어가면 오버플로우가 발생해서 다시 1970년을 가리켜버리게 된다. 오버플로우가 발생하는 시간을 계산해보면 2038년쯤이다. 이는 32비트 integer를 사용하면 그렇다. 현재는 64비트 integer를 사용하여 나중일로 문제를 미뤄서 해결한다. 이때 tv_usec의 u는 밀리세컨트의 \(\mu\)와 비슷하여 앞에 u를 붙인다.

timezone는 현재 사용하지 않으며, NULL을 넣으면 된다.

POSIX Clocks

  • int clock_getres(clockid_t clock_id, struct timespec *res)
  • int clock_gettime(clockid_t clock_id, struct timespec *ts)
  • int clock_settime(clockid_t clock_id, const struct timespec *ts)
1
2
3
4
struct timespec {
    time_t tv_sec; /* seconds */
    long tv_nsec; /* nanoseconds */
};
  • clockid_t
    • CLOCK_REALTIME
    • CLOCK_MONOTONIC
    • CLOCK_PROCESS_CPUTIME_ID
    • CLOCK_THREAD_CPUTIME_ID

CLOCK_REALTIME는 말 그대로 Real Time을 얻어내고자 할때 사용한다. 하드웨어 시계로 RTC가 사용된다. Real Time은 사용자가 설정할 수 있으며, 수정할 때 Root 권한이 필요하다. CLOCK_MONOTONIC는 시스템이 부팅 이후의 시간을 엄격하게 측정한다. 하드웨어 시계로 HPET가 사용된다. CLOCK_PROCESS_CPUTIME_ID와 CLOCK_THREAD_CPUTIME_ID는 각각 프로세스와 쓰레드가 실제 실행된 시간(Blocking된 시간을 제외)을 얻어내고자 할 때 사용한다. 하드웨어 시계로 TSC가 사용된다. 만약 Multi Thread를 갖는 Process라면, 각 프로세스가 실행된 시간이 전부 합산된 값으로 Process Time이 반환된다.

tv_nsec는 \(10^9\)가 넘어가면 1초로 올림한다. 위의 tv_usec는 \(10^6\)가 넘어갈 때 1초로 올림한다. s -> ms -> us -> ns 순서로 \(\frac{1}{1000}\)씩 작아진다.

clock_getres는 시계의 측정 가능한 최소 시간 단위, 즉 resolution를 얻어낸다. clockid_t를 각각 다르게 넣어본 결과는 다음과 같다.

  • CLOCK_REALTIME : sec=0 nsec=4000250
  • CLOCK_MONOTONIC : sec=0 nsec=4000250
  • CLOCK_PROCESS_CPUTIME_ID : sec=0 nsec=1
  • CLOCK_THREAD_CPUTIME_ID : sec=0 nsec=1

위 결과로 알 수 있는 점은, 아래 두개는 역시 TSC를 사용하여 높은 resolution로 시간을 측정할 수 있구나 라는 점이다. 위의 두개는, 우리가 설정한 HZ=250에 의하여 4ms당 한번씩 jiffices counter가 올라가는구나. 라는점을 알 수 있다.

clock_gettime()를 통해 시간을 얻어낼 수 있고, clock_settime()를 통해 시간을 설정할 수 있다. 다만 아무 시간이나 조절할 수 없다. 위에서 말했듯 Monotonic 시간은 사용자가 설정할 수 없다. Real time만 설정 가능하며, Root 권한이 필요하다. 두 함수 모두 성공시 0을, 실패시 -1을 반환한다.

Sleep

  • unsigned int sleep(unsigned int seconds)
  • int nanosleep(const struct timespec *req, struct timespec *rem)
  • int clock_nanosleep(clockid_t clock_id, int flags, const struct timespec *req, struct timespec *rem)

sleep()은 간단히 seconds 시간동안 Process가 Blocking State에 들어간다. 운영체제는 Realtime Clock을 통해 시간을 체크하여, 시간이 다 지났으면 Process의 Blocking 상태를 다시 Ready 상태로 돌려놓는다. sleep() 도중에 시그널 같은거를 받아서 중간에 Blocking 상태가 풀릴 수 있다. 이렇게 덜 잔 경우, 얼마나 덜 잤는지 시간을 반환한다. 주의할 점은, sleep은 덜 자더라도 에러라고 보지 않기 때문에 errno에 아무것도 기록하지 않는다. 따라서 특정 초만큼 반드시 sleep하도록 보장하고 싶을 때는, 아래 코드와 같이 구현하면 된다.

1
2
3
4
5
void ensure_sleep(unsigned int s)
{
    while ((s = sleep (s))) 
        // Sleep!
}

nanossleep()은 sleep할 시간을 nanosecond 단위로 설정할 수 있고, 인자로 timespec 구조체를 넘겨야 한다. nanoseleep도 중간에 Blocking 상태에서 강제로 풀리는 경우가 있을 수 있기 때문에, 두번째 인자값으로 덜 잔 시간과 -1을 반환한다. 중간에 특별한 예외상황이 없다면 0을 반환한다.

sleep()과 nanosleep()는 Realtime Clock을 사용해서 시간을 재는데, clock_nanosleep()을 사용하여 사용할 POSIX Clocks을 명시할 수 있다.

Flag 값으로는 0 또는 TIMER_ABSTIME을 넘길 수 있다. TIMER_ABSTIME을 사용하면, 절대 시간(예를들어 2024년 12월 11일 18:00시 까지)까지 sleep을 걸어놓을 수 있다. 이 기능을 사용하려면 반드시 Realtime Clock을 사용해야 한다.

Async-signal-safe Function

시그널 핸들러 내에서는 async-signal-safe한 함수만 사용하는것이 좋다고 했다. printf, exit, scanf와 같은 함수는 non-async-signal-safe 함수이기 때문에, 대체제로 write, _exit 함수를 사용하는것이 좋다.

1
2
3
4
5
6
7
void my_handler(int signum)
{
    char *str = "Wake up!\n"
    
    write(1, str, strlen(str)); // printf 대신 write(1, ...)
    _exit(0);  // exit(0) 대신 _exit(0)
}

이때 write의 Default File Descriptor는 0이 Standard Input, 1이 Standard Output, 2가 Standard Error로 정해져있다.

Alarm

  • unsigned int alarm(unsigned int seconds)

일정 시간 뒤에 my process에게 알람을 보내줄 수 있다. 알람은 ‘SIGALRM’ Signal 형태로 들어온다. 따라서 SIGALRM에 대한 시그널 핸들러를 설정해줘야 한다.

알람은 하나만 존재 가능하다. 설정한 알람이 실행되기 전에 다른 알람을 설정하면, 설정해뒀던 알람이 Cancel된다. Cancel된 알람은 몇 초 뒤에 알람이 울릴 예정이었는지 그 시간을 반환한다.

alarm(0)을 사용하면 0초 뒤에 바로 알람을 울리라는 뜻이 아니다. 기존의 설정된 알람을 Cancel 해달라는 뜻이고, SIGALRM 시그널이 호출되지 않는다.

Overruns

요총한 시간보다 더 잠드는 현상을 Overrun이라고 한다. 실제 3초를 sleep한 시간을 측정해보면 3000533853 ns 라고 찍힌다. 왜 500ms만큼 오차가 나는가? 그 이유는 여러가지가 존재한다.

  1. sleep 함수를 실행하고, 리턴하는데 시간이 걸리기 때문에.
  2. 최악의 경우 Hardware Clock의 Interaction 주기만큼 오차가 발생 가능. 그 이유는, 만약 Interaction 주기가 3초라고 가정하자. Interaction이 발생해서 sleep된 시간을 계산해봤더니 2.99s가 나왔다. 이 경우 시간이 덜 되었기 때문에 프로세스를 깨우지 않을 것이다. 이후 다음 Interaction이 발생하면 5.99초 뒤에 깨우게 되므로, Interaction 주기가 클 수록 오차가 크게 발생할 수 있다.

Timers

  • Oneshot Timer
  • Interval Timer

Oneshot Timer는 일정 시간 뒤에 딱 한번만 발생하는 Timer고, Interval Timer는 주기마다 계속 실행되는 Timer이다.

Interval Timers

  • int setitimer(int which, const struct itimerval *value, struct itimerval *ovalue)
  • int getitimer(int which, struct itimerval *value)
1
2
3
4
struct itimerval {
    struct timeval it_interval; /* next value */ 
    struct timeval it_value; /* current value */
};
  • which
    • ITIMER_REAL
    • ITIMER_VIRTUAL
    • ITIMER_PROF

which는 Timer Type를 지정하는 인자값이다. POSIX가 정의되기 전에 만들어진 함수이기 때문에, 규격이 다르다. ITIMER_REAL는 RTC Clock을 사용한다. ITIMER_VIRTUAL는 프로세스가 User Space에서 흐르는 시간만 고려하며, TSC를 사용한다. ITIMER_PROF는 Process가 User Space와 Kernel Space에서 흐르는 시간을 둘다 고려한다. Hardware Clock으로 TSC + APIC/HPET를 조합하여 사용한다.

Timer가 Expire (지정한 시간이 경과)될 때 Signal을 발생시키며, REAL, VIRTUAL, PROF가 발생하는 시그널이 다 다르다. 각각 SIGALRM, SIGVTALRM, SIGPROF에 대응한다.

itimerval 구조체는 Timer의 주기(it_interval)와 Time Value를 담을 Temp 변수(it_value)가 담겨있다. Hardware Clock의 Interrupt가 발생할 때마다 it_value 값을 감소시키며, it_value 값이 0이 되었을 때 시그널을 발생시킨다. 이후 it_value값을 it_interval값으로 초기화한다. it_value를 초기에 지정해줄 수도 있다. 따로 지정할 경우 최초에 타이머가 실행될 Delay를 주는 것과 같다. it_interval에 0을 넣고, it_value에 시간을 지정해두면 Oneshot Timer와 동일해진다.

각 Clock마다 하나의 Timer만 설정이 가능하다. 만약 Realtime에 대한 Timer가 돌아가고 있는데, 다Realtimer에 대한 Timer를 설정하면 기존의 Timer가 덮어씌워진다. 그리고 기존의 itimerval 값은 ovalue 인자값에 담겨서 반환된다.

[!error]- alarm()과 setitimer()를 같이 사용하지 마라.{title} 두 함수는 서로 Realtime Clock을 공유한다. Timer는 Clock당 하나만 들어갈 수 있으므로, alarm()와 setititimer()를 동시에 사용하면 충돌이 날 수 있다. 일반적으로 둘 중 하나를 써야한다면 setitimer()를 권장한다.

gettimer()를 사용하면 Clock에서 돌아가고있는 Timer의 itimerval을 반환받을 수 있다. 성공하면 0을 반환하고, 실패하면 -1을 반환한다.

itimer는 사용하기 간단하지만, 4가지 단점이 존재한다.

  1. 각 Clock당 하나의 Timer만 설정 가능.
  2. 발생하는 Signal의 종류를 바꿀 수 없음.
  3. Signal이 Block되면, 그동안 발생한 Event가 무시됨.
  4. 설정 가능한 시간이 microsecond가 최대임.

POSIX Timers

  • int timer_create(clockid_t clockid, struct sigevent *evp, timer_t *timerid)
  • int timer_settime(timer_t timerid, int flags, const struct itimerspec *value, struct itimerspec *ovalue)
  • int timer_gettime(timer_t timerid, struct itimerspec *value)
  • int timer_getoverrun(timer_t timerid)
  • int timer_delete(timer_t timerid)
1
2
3
4
struct itimerspec { 
    struct timespec it_interval; /* next value */ 
    struct timespec it_value; /* current value */
};
1
2
3
4
5
6
7
struct sigevent {
    int           sigev_notify;       // 알림 방식 지정
    int           sigev_signo;        // 발생시킬 신호 (SIGEV_SIGNAL인 경우)
    union sigval  sigev_value;        // 타이머 만료 시 전달할 데이터
    void          (*sigev_notify_function)(union sigval); // SIGEV_THREAD에서 호출할 함수
    void          *sigev_notify_attributes;  // 스레드 속성 (SIGEV_THREAD인 경우)
};
  • clockid_t
    • CLOCK_REALTIME
    • CLOCK_MONOTONIC
    • CLOCK_PROCESS_CPUTIME_ID
    • CLOCK_THREAD_CPUTIME_ID
  • sigev_notify
    • SIGEV_NONE
    • SIGEV_SIGNAL
    • SIGEV_THREAD
    • SIGEV_THREAD_ID

timer_create() 는 Timer를 생성한다. 생성만 하고, Timer를 실행시키려면 timer_settime()을 사용해야 한다. Clock는 POSIX Clock을 사용한다. sigevent 구조체를 통해 Timer의 설정값을 다룰 수 있다. Timer 생성에 성공하면 0과 timerid를 반환한다. 실패하면 -1을 반환한다.

Timer가 expire될 때 어떤 동작을 할 건지 설정이 가능하다. struct sigevent evp;를 생성했다고 하면, 가장 먼저 evp.sigev_notify 값을 설정해야 한다. SIGEV_NONE는 expire되어도 아무것도 안한다. SIGEV_SIGNAL은 시그널을 발생시키며, 어떤 시그널을 발생시킬지 evp.sigev_signo로 지정 가능하다. Signal은 POSIX 시그널을 사용하며, POSIX 시그널은 데이터와 함께 전송할 수 있다. 기본값으로 데이터는 timerid를 담아 전송된다. timerid가 담기는 이유는, Signal Handler 입장에서 Timer1에서 전송된 시그널인지 Timer2에서 전송된 시그널인지 timerid 정보가 없다면 구분할 수 있는 방법이 없기 때문이다. SIGEV_THREAD는 expire될 때 evp.sigev_notify_function에 담긴 함수를 메인으로 갖는 Thread를 실행시킨다. SIGEV_THREAD_IDexp.sigev_notify_thread_id에 담긴 Thread ID에게 설정된 evp.sigev_signo 시그널을 보낸다.

evp의 NULL을 넣으면 Default 설정이 할당된다. Default 설정은 SIGEV_SIGNAL, SIGALRM + TimerID가 기본값이다.

timer_settime() 을 사용하여 생성한 Timer를 실행한다. flags에는 0 또는 TIMER_ABSTIME 값을 넣을 수 있다. TIMER_ABSTIME을 지정하면 특성상 Oneshot Timer, Realtime Clock만 사용 가능하다. Realtime 값을 ittimespace의 it_value에다 저장하면 된다.

timer를 실행시키기 위해 timer_settime() 함수를 사용한다. flag에는 0 또는값을 넣을 수 있다. TIMER_ABSTIME는 Realtime으로 설정된 Timer에서만 사용할 수 있다. 절대 시간은 ittimespec의 it_value에다가 저장해두면 되고 특성상 주기적으로 실행시킬 수 없다.

만약 Timerid가 실행중인데 timer_settime()으로 재설정하게 되면 기존 Timer는 덮어씌워진다. 실행이 성공하면 0과 기존에 사용되던 itimerspec가 ovalue에 담겨서 반환된다. 실패하면 -1을 반환한다.

timer_gettime() 은 현재 실행중인 timer의 ittimerspec을 얻어낼 수 있다.

timer_getoverrun() 는 이전에 발생했지만 처리되지 않은 event 수를 반환한다. 보통 Signal Handler 내부에서 처리하며 처리되지 않은 함수가 있다면 그만큼 Signal Handler를 더 실행해주면 된다. async-signal-safe한 함수다.

timer_delete() 를 통해 Timer를 삭제할 수 있다. 삭제에 성공하면 0, 실패하면 -1을 반환한다.

[!success]- POSIX Timer는 itimer의 단점을 모두 커버한다.{title}

  1. 같은 Clock 에서도 여러개의 Timer를 실행할 수 있다.
  2. 발생하는 Signal의 종류를 바꿀 수 있다.
  3. 만약 Signal이 무시되었을 때, 얼마나 무시되었는지 개수를 확인할 수 있다.
  4. Interval 설정을 nanosecond 단위까지 가능하다.

[!tip] POSIX 관련 함수를 쓸 때는 핫앙 컴파일 할때 뒤에 -lrt를 붙여라.{title}