Post

리눅스 - 타이머

타이머

1. 개요

시스템 내에서 시간을 아는건 중요하다.
이는 주기적으로 처리해야하는 Task뿐만 아니라 예약된 Task를 처리할 때도 필요하기 때문이다.
커널이 시간의 흐름을 측정하는데 필요한 기능을 하드웨어에서 제공하는데, 이를 시스템 타이머라고 한다. 시스템 타이머는 전자시계나 프로세스 주파수와 같은 전기적 시간 신호를 이용해 동작하며 미리 설정된 주파수마다 울리게 된다.
시스템 타이머가 울리면 인터럽트가 발생하고 커널에서는 대상 인터럽트 핸들러를 통해 이를 처리한다.

이미 커널에서는 진동수를 알기 때문에 타이머 인터럽트 사이의 경과시간을 알 수 있는데, 이를 틱(Tick)이라고 부르며 이 값은 진동수 분의 1초가 된다. 이러한 방식을 이용하여 현재 시각과 시스템 가동 시간을 기록한다. 사용자에게 제공되는 시간 역시 커널에서 얻어온 시간을 이용해서 계산되며 제공되며 그외 시스템을 운용하는데 많이 사용된다.

아래는 타이머 인터럽트를 통해 주기적으로 실행되는 작업의 목록이다.

  • 시스템 가동시간 갱신
  • 현재 시각 갱신
  • 설정 시간에 다다른 동적 타이머의 실행
  • 자원 사용현황과 프로세스 시간 통계 갱신

2. 진동수(Hz)와 리눅스에서 처리 방식

1) 진동 수의 처리

개요에서 말했다시피 시스템 타이머가 울리면 인터럽트가 발생한다. 이 시스템 타이머는 미리 설정된 주파수마다 울리게 되는데 이 주파수는 아키텍처 별로 다르다. 따라서 리눅스에서는 아키텍처별로 다르게 처리해준다. 코드를 살펴보면 “include/asm-generic/param.h”에 USER_HZ가 100이라고 명시되어있다. 다르게 쓰는 아키텍처의 경우 alpha가 유일한 것으로 보이며 “arch/alpha/include/asm/param.h”에는 1024로 잡혀있다.
즉, alpha를 제외한 다른 아키텍처에서는 100, alpha에서만 1024로 잡혀있단 소리이다. 이는 alpha를 제외한 아키텍처에서 타이머 인터럽트의 주파수는 100HZ가 되며 초당 100회, 10 밀리초당 한번 발생한다는 말이며, alpha 아키텍처에서는 1024HZ로 초당 1024회 발생한다는 말이다.

이 값이 높아진다면 타이머 인터럽트의 세밀도가 올라간다. 앞서 언급했듯이 커널에서는 이 타이머 인터럽트를 통해 시간의 흐름을 체크한다.
따라서 10밀리초 단위의 주기적인 작업을 실행할 것이라면 100HZ에서는 정확하게 구동되겠지만 더 작은 단위의 주기적인 작업의 경우 정확하게 실행하는 것을 보장할 수 없다. 즉, 시간 이벤트의 정확도는 HZ값에 비례한다. 따라서 어떤 작업 대기를 위한 타임아웃 값이나 측정값, 심지어 프로세스 선점가지도 더 정확하게 처리된다.

이렇게만 들으면 매우 좋은 이유만 있는 것 같지만 생각해볼 부분이 있다. HZ 값에 비례하여 시스템 타이머가 작동한다는 것은 HZ에 비례하여 타이머 인터럽트가 호출된다는 뜻과 같다. 이는 어떤 작업을 하는 도중에 타이머 인터럽트가 끼어들어 처리되고 빠지는 시간이 늘어난다는 소리이다. 이 때문에 100에서 1000으로 늘었다가 현재 기본값은 100으로 다시 줄었다.
하지만 이를 변경하여 커널을 컴파일 할 수 있기 때문에 변경이 필요하다면 코드를 수정해서 구동할 수 있다.

2) 지피(jiffies)

전역 변수인 jiffies에는 시스템 시작 이후에 발생한 진동 횟수가 저장된다. 이 값은 타이머 인터럽트가 발생할때마다 1씩 늘어난다. 타이머 인터럽트는 HZ값에 비례하기 때문에 초당 지피의 증가율을 HZ가 된다. 이 값이 0부터 시작한다면 지피값/HZ를 하면 시스템이 구동되고 가동 시간을 구할 수 있겠지만 버그 식별을 위해 지피값에 overflow가 더 자주 일어나게 하기 위해 특별한 값으로 초기화 하였다. 따라서 실제 가동시간을 구하고 싶다면 해당 값을 빼고 나눠주어야한다.

jiffies 변수는 “include/linux/jiffies.h”에 아래와 같이 선언되어있다.

1
extern unsigned long volatile jiffies;

외부 변수(extern)이며, MSB를 부호비트가 아닌 값을 표기하는데 사용하고(unsigned), 8byte이며 (long, 정확히는 64bit 아키텍처 기준 windows 제외 8byte), volatile 로 명시적으로 해당 변수를 레지스터 변수로 사용할수 없게 선언되어있다.

long에 대한 설명을 보고 저게 뭔가 싶겠지만, 애시당초 long 자료형은 32bit 아키텍처냐 64bit 아키텍처냐 심지어 윈도우냐에 따라 크기가 달라진다. 여기서 기존 커널 코드의 호환성을 유지하기 위해 이 unsigned long 자료형을 그대로 유지해야했기 때문에 64bit를 넘어와서 jiffies_64라는 새로운 변수를 만들었고, 두 변수를 겹침으로 jiffies 변수를 jiffies_64 변수의 하위 32비트로 만듬으로써 해결 했다.

64bit 크기만큼 처리할 수 있다고해도 결국에는 해당 변수가 꽉 차버리는 일이 발생한다. 이때 지피 값은 0으로 돌아가게 되는데 지피 값을 기준으로 커널 내부에 데드라인을 체크하고 있었을씨 비교문에서 문제가 발생할 수 있다. 이때 제대로 비교하기 위한 매크로를 제공하며 “include/linux/jiffies.h” 파일에 네가지 형태로 정의되어있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
 * time_after - returns true if the time a is after time b.
 * @a: first comparable as unsigned long
 * @b: second comparable as unsigned long
 *
 * Do this with "<0" and ">=0" to only test the sign of the result. A
 * good compiler would generate better code (and a really good compiler
 * wouldn't care). Gcc is currently neither.
 *
 * Return: %true is time a is after time b, otherwise %false.
 */
#define time_after(a,b)		\
	(typecheck(unsigned long, a) && \
	 typecheck(unsigned long, b) && \
	 ((long)((b) - (a)) < 0))
/**
 * time_before - returns true if the time a is before time b.
 * @a: first comparable as unsigned long
 * @b: second comparable as unsigned long
 *
 * Return: %true is time a is before time b, otherwise %false.
 */
#define time_before(a,b)	time_after(b,a)

/**
 * time_after_eq - returns true if the time a is after or the same as time b.
 * @a: first comparable as unsigned long
 * @b: second comparable as unsigned long
 *
 * Return: %true is time a is after or the same as time b, otherwise %false.
 */
#define time_after_eq(a,b)	\
	(typecheck(unsigned long, a) && \
	 typecheck(unsigned long, b) && \
	 ((long)((a) - (b)) >= 0))
/**
 * time_before_eq - returns true if the time a is before or the same as time b.
 * @a: first comparable as unsigned long
 * @b: second comparable as unsigned long
 *
 * Return: %true is time a is before or the same as time b, otherwise %false.
 */
#define time_before_eq(a,b)	time_after_eq(b,a)

3. 타이머 인터럽트

타이머 인터럽트는 아키텍처 종속적인 부분과 아키텍처 독립저인 부분으로 나뉜다. 아키텍처 종속적인 부분은 시스템 타이머의 인터럽트 핸드러 형태로 되어있으며 타이머 인터럽트가 발생했을 때 실행된다. 핸들러의 정학한 작업은 아키텍처 마다 다르지만 최소한 아래의 작업은 처리한다.

  • jiffies_64 및 현재 시간 ㅈ장을 위한 xtime 변수에 안전하게 접근하기 위해 xtime_lock을 얻는다.
  • 필요에 따라 시스템 타이머를 확인하고 재설정한다.
  • 갱신된 현재 시간을 주기적으로 실시간 시계에 반영한다.
  • 아키텍처 종속적 타이머 함수인 tick_periodic() 함수를 호출 한다.

tick_periodic() 함수는 아래와 같은 작업을 수행한다.

  • jiffies_64 카운터 값을 1 증가시킨다.
  • 현재 실행 중인 프로세스가 소모한 시스템 시간, 사용자 시간과 같은 자원 사용 통계값을 갱신한다.
  • 설정시간이 지난 동적 타이머를 실행한다.
  • xtime에 저장된 현재 시간르 갱신한다.
  • scheduler_tick() 함수를 실행한다.

위의 작업들은 함수를 호출해서 처리한다.

4. 실행 지연

드라이버 같은 코드의 경우 일정 시간 실행을 지연시켜야하는 경우가 있다. 짧은 시간이라 후반부 처리와 같은 것을 쓰면 오히려 손해인 경우가 있는데, 이럴때는 몇가지 해결책을 통해 해결한다.

1) 프로세서 독점

프로세서를 독점한다면 정확하게 대기 후에 사용할 수 있겠지만 다른 프로세스가 프로세서를 사용하지 못하기 때문에 프로세서 효율성이 낮아진다. 이 경우 루프 반복, 즉 바쁜 대기(busy wait)를 통해 특정 시간만큼 루프를 반복한 뒤 처리하는 식이다. 몇번 루프당 얼마의 시간이 지나는지 어떻게 아는 것인가? 그건 바로 지정된 시간동안 프로세서가 처리할 수 있는 루프 값(BogoMips)을 알기 때문에 이 값을 통해 몇번의 루프를 돌면 원하는 시간만큼 대기 할 수 있는지 알고 있다.

2) 프로세스 독점하지 않으나 실행시간 보장 어려움

프로세스를 새로 스케줄링 하는 방식이다. 하지만 이 방법의 경우 프로세스 컨텍스트에서나 가능하기 때문에 인터럽트 핸들러에서는 사용할 수 없다.

참고문헌

  • 리눅스 커널 심층분석 (에이콘 임베디드 시스템프로그래밍 시리즈 33, 로버트 러브 저자(글) · 황정동 번역)
  • 리눅스 커널 6.6.7 버전
This post is licensed under CC BY 4.0 by the author.