SturdyCobble's Study Note

[프로그래밍] 6.3 register, volatile & 메모리의 구조 본문

휴지통/['19.06-'20.07]프로그래밍101

[프로그래밍] 6.3 register, volatile & 메모리의 구조

StudyingCobble 2020. 2. 13. 19:57

NOTICE : 독학인 만큼, 잘못된 내용이 있을 수 있습니다. 내용 지적은 언제나 환영합니다.

더 이상 이 블로그에는 글을 올리지는 않을 예정입니다. 그렇지만 댓글을 달아주시면 최대한 답변드리고자 노력하겠습니다.


※이 글은 프로그래밍 언어에 대한 기초적인 이해를 가정하고 있습니다. 최소 프로그래밍 언어 하나 정도를 약간이라도 접해보시는 것을 추천합니다. 또한, 이 글은 심화 내용은 되도록 피하여 서술했습니다.

 

(제목의 기억 부류 지정자는 Storage Class Specifier를 번역한 것입니다. 기억 영역 클래스 지정자, 기억 클래스 지정자 등 다양한 번역이 존재하지만, 클래스라고 번역하는 것은 class 키워드로 지정되는 그 클래스와 혼동될 우려가 있어 위의 용어로 사용하였습니다. Microsoft Docs에서는 스토리지 클래스 지정자로 표기하고 있습니다.)

 

 이번 글에서는 register, volatile 지정자와 메모리에 관한 내용을 다룹니다. 사실 volatile 지정자는 C언어의 기억 부류 지정자로 분류되지 않지만, 메모리에 관한 이야기를 하기 위해 같이 다룰 예정입니다.

 

 register 지정자는 C/C++에만 존재하며, volatile 지정자는 C/C++, Java에 존재하는 지정자입니다. register 지정자는 CPU의 레지스터에 변수가 우선적으로 할당될 수 있도록 하며 volatile 지정자는 CPU 레지스터가 아닌 메모리에서만 변수가 저장되도록 설정합니다. 좀 더 자세히 말하자면, register 지정자는 변수에 대한 접근 속도가 최대한 빠르도록 하는 지정자이고, volatile 지정자는 그러한 최적화를 방지하는 지정자입니다. (이러한 구현은 컴파일러나 언어마다 약간씩 차이가 있을 수 있습니다.)

 

 사용법은 다음과 같이 간단합니다.

register int regVar;
volatile int volVar;

 

 이러한 지정자가 있는 변수들의 동작 과정을 이해하기 위해서는 컴퓨터 메모리에 대한 약간의 이해가 필요합니다. (이번 글에서는 간단하게만 다루어 보겠습니다.)

 

 컴퓨터는 동작하기 위해 저장소와 처리 장치가 필요합니다. 저장소에는 HDD와 SSD같은 보조 기억 장치, RAM과 같은 주 기억 장치가 포함됩니다. 특히 주 기억 장치는 컴퓨터를 끄는 순간 데이터가 날아가기에 휘발성 메모리(Volatile Memory)에 속합니다. 

그러나 이러한 저장소 외에도 CPU에서 처리중인 데이터를 저장하기 위해 사용되는 프로세서 레지스터(Processor Register)와 레지스터는 아니지만, 빠른 접근을 위해 CPU에 포함되어 있는 저장 공간인 캐시(Cache)가 있습니다. 이러한 저장소는 다음과 같은 식의 계층 구조(Hierarchy)를 이룹니다.

 

(https://brunch.co.kr/@toughrogrammer/14를 참고하여 제작됨)

빠를 수록 작아지고, 느릴 수록 커지는 형태를 나타냅니다. 특별히 프로그램 실행 중에 주로 사용되는 공간은 레지스터부터 L1~L3(L4까지 있는 경우도 있습니다.) 캐시, 메모리 부분입니다. 일반적으로 메모리에 해당 내용이 저장되지만, 빠른 접근이 필요한 경우 캐시나 레지스터에 저장이 되고, 연산 과정 중이라면 레지스터로 그 값이 옮겨지게 됩니다. 

 

 register 지정자는 이러한 부분에서 접근이 빠를 수 있게 우선 순위를 주는 것입니다. 대규모로 반복문을 돌려야 할 때, 이러한 변수는 반복적으로 접근이 필요하기 때문에 이런 방법을 고려해볼 수 있을 것입니다.

 

 그러나, 컴파일러는 이러한 방식이외에도 다양한 방식으로 메모리를 최적화합니다. 다음과 같은 코드가 대표적인 예시입니다. (물론, 그 방식은 컴파일러마다 약간씩 차이가 있을 수 있습니다.)

int var = 0;
for(int i=1;i<=10;i++){
	var +=1;
}

위의 코드는 var라는 변수에 10번이나 1을 더하는 코드입니다. 단순히 10을 더하기 위해 이렇게 썼다면, 매우 비효율적인 코드라고 할 수 있습니다. 그러나 이런 코드를 컴파일러는 다음과 같은 코드로 바꾸어 최적화 해볼 수 있습니다.

int var = 0;
var = 10;

 

volatile 지정자는 이러한 과정에서 최적화를 방지합니다. 지금까지 봤던 간단한 코드들에서는 의미가 없겠지만, 만약 변수값을 다른 프로그램이 변경할 수 있다면 이야기가 달라집니다. 만약, 다른 프로그램이 특정 변수 bar의 값을 변경할 수 있다고 생각해봅시다. 이 때 이 값이 1로 변경되면 루프를 끝내는 프로그램을 만든다고 생각해봅시다. 그러면 volatile을 배우기 전에 다음의 코드를 생각하게 될 것입니다.

static int bar;

//뭔가 그럴듯한 코드

bar = 0;
while (bar != 1)
  //뭔가 그럴듯한 코드

//뭔가 그럴듯한 코드

하지만, 이 코드는 다음과 같이 최적화될 수 있습니다.

//뭔가 그럴듯한 코드

while (true)
  //뭔가 그럴듯한 코드

//뭔가 그럴듯한 코드

이 경우, 그냥 무한 루프가 됩니다. 이를 방지하기 위해 volatile 지정자를 붙여줍니다.

static volatile int bar;

//뭔가 그럴듯한 코드

bar = 0;
while (bar != 1)
  //뭔가 그럴듯한 코드

//뭔가 그럴듯한 코드

 

또한 volatile 변수는 캐시 메모리 같은 곳에 변수를 저장하지 않게 하여, 변수를 참조할 때, 메모리에 저장된 그 값에 직접 접근할 수 있도록 한다는 특징도 있습니다. 이러한 특징은 하드웨어를 제어하거나, MMIO(Memory-mapped I/O : 입출력과 메모리가 하나의 메모리 공간에서 관리되는 것) 등을 위해서 사용됩니다.

 

 하지만, 이는 멀티 스레드 프로그램을 구현할 때의 경우 문제가 발생할 수 있습니다. (C/C++) 이는 여러 연산들의 순서를 강제하도록 하는 메모리 배리어(Memory Barrier)를 확실히 보장하지 못합니다. 반대로 Java (Java 5 이후 버전)의 volatile은 이러한 기능을 보장한다는 점에서  C/C++과 다릅니다. (이에 관련된 내용은 링크1(Wikipedia), 링크2(StackOverflow)를 참고하시면 좋을 것 같습니다. 이는 C++11이후 Atomic Variables를 이용하여 보장될 수 있습니다.

 

 

 지금까지 큰 틀에서 메모리를 살펴보았다면, 더 좁은 의미의 메모리(주 기억 장치)에 대해 살펴보겠습니다. 메모리는 운영체제에 의해 관리되어 프로그램에 할당됩니다. 메모리는 1차원의 공간으로 구성되며, 각 공간의 주소는 일반적으로 16진수로 표기합니다. 이는 일반적으로 메모리와 관련된 단위들이 2의 거듭제곱 진법 형태를 가지기 때문입니다. 16진수는 다른 2,8 진법 등으로 쉽게 변환할 수 있습니다. 아래는 그 예시를 보여줍니다.

 

 실제로 메모리를 사용할 때는 물리적인 주소가 아닌, 가상적인 주소를 가지고 사용하게 됩니다. 특히 이 과정에서 보조 기억 장치의 메모리를 가져와 더 큰 메모리를 제공하게 할 수도 있습니다. 이러한 기술을 '가상 메모리'라고 합니다. 아래 그림은 이를 간략하게 보여줍니다.

 

Virtual Memory(per process)

 

 

이러한 메모리는 다음과 같은 구조로 관리가 됩니다. 아래 그림은 C/C++에서 프로그램에 할당되는 메모리가 관리되는 모습을 시각화한 것입니다. 

 코드 부분에는 말 그대로 코드가 저장됩니다. 그냥 코드는 아니고 기계어로 변환된 코드와 문자열과 같은 상수값들이 저장됩니다. (문자열과 같이 read-only인 상수 값들은 text영역에 저장됩니다.)

 데이터 부분에는 프로그램 실행 내내 저장되는 변수들, 즉 전역 변수와 정적 변수가 저장됩니다. 초기화 되지 않은 값은 BSS(Block State Symbol) 영역에 저장됩니다. 이는 변수가 있을 공간만 확보해두고, 실제로 그 값을 저장하지는 않습니다. (이는 런타임 도중에 확보됩니다.) 코드와 데이터 부분은 컴파일 도중에 확보되는 영역입니다.  반대로, 힙과 스택은 실행 도중에 확보되는 영역입니다. 

 

Stack의 작동 원리

 

 힙은 동적으로 할당되는 영역입니다. 이에 대해서는 나중에 다룰 예정입니다. 프로그램 실행 도중 그 크기가 변경되어야 할 때 직접 사용자가 할당하는 영역으로 위에서 아래로 채워집니다. (그림 상에서의 위치를 이야기 한 것입니다.)

 

 스택은 아래서 위로 채워지며, 지역변수와 매개변수 등 함수와 관련된 변수들이라고 보면 됩니다. 함수가 소멸될 때 해당 변수들은 메모리상에서 소멸됩니다. (스택 용량이 초과되는 경우, 이를 Stack Overflow라고 합니다. 이 경우 오류가 발생합니다.)

이름이 나타내듯이 후입선출 (Last In First Out : LIFO) 방식으로 작동해 나중에 저장된 (스택에서 이러한 동작을 push라고 합니다.) 값이 가장 먼저 인출되게 됩니다. (이러한 동작은 pop이라고 합니다.) 접시가 쌓여있는 모습을 생각하시면 좋을 것 같습니다. 가장 위에 놓인 접시는 가장 늦게 놓여진 접시이자 가장 먼저 사용되는 접시입니다.

 

 

스택은 그냥 데이터를 쌓고 지우므로 빠르지만, 힙은 빈 공간이 있는지 확인하고 저장하기 때문에 속도가 느립니다. 대신 힙은 언제든지 데이터를 삭제하고 추가할 수 있습니다. 스택 영역이 stack 방식으로 데이터를 쌓는 이유를 이해하려면, 스택 프레임(Stack Frame)에 대해서 알아야 합니다.  이는 스택에 차곡차곡 저장되는 함수의 호출 정보를 나타냅니다. 아래는 스택 프레임의 작동 방식을 보여줍니다.

 

 

프로그래밍 언어마다 이러한 형태는 차이가 있습니다. 예를 들어 Python에는 전역 변수가 없으므로, 전역 변수가 저장되는 공간은 없을 것입니다. 특별히 Python은 Python Memory Manager, Java는 Java Virtual Machine에서 메모리를 관리하므로, 사용자가 동적 할당(Dynamic Memory Allocation)을 할 일은 거의 없습니다. 그러나 각 언어마다 용어가 달라서 하나로 통일하기는 힘들지만, '스택'과 '힙'은 대부분의 상황에서 사용되는 개념인 것 같습니다. (static 영역이 heap영역에 합쳐지는 등의 경우는 있지만, 대체로 stack과 heap의 개념은 찾아볼 수 있었습니다.)

 

 특별히 Java의 경우 int와 같은 Primitive Data Type들의 경우만 stack에 저장되고, 나머지 Reference Variables는 heap에 저장되게 됩니다. Python의 경우도 비슷하게 heap 공간을 사용하고, 이 공간은 가상 머신이 관리하게 됩니다. 

 

이러한 경우 스택에는 함수의 호출 정보(C/C++의 경우와 같이)와, 참조 변수들의 주소 등이 저장됩니다.

 

 메모리가 동적으로 관리되는 Python과 Java는 Garbage Collection(직역하면 쓰레기 수집입니다.)이라는 과정을 통해 사용되지 않는 메모리를 해제합니다. 이는 스택과 힙의 개념을 통해서 함께 이해할 수 있습니다. 실제 과정은 더 복잡하지만 단순화하자면, heap에 저장된 값이 unreachable, 즉 도달할 수 없으면 제거합니다. 

왼쪽은 str1이라는 String 변수가 Heap의 한 값을 참조하는 것을 보여줍니다. 그러나 str1의 값이 변경되면, 기존에 존재하던 문자열을 두고 새로운 값이 Heap에 할당되어 연결됩니다. 그러면 기존에 있던 문자열은 도달할 수 없게 되고, Garbage Collector가 실행되면서 이 값을 만나게 되면, 메모리에서 제거되는 것입니다.

 

이렇게 해서 메모리에 관한 기초적인 내용까지 다루어 보았습니다.

Comments