리눅스 메모리 관리

seunghyun·2024년 7월 31일
0

촤라락

목록 보기
4/4

💡 추천하는 분: 알고 있는 내용을 촤라락 정리하고 싶으신 분

다루는 내용

  • 메모리가 관리되는 방법
  • 리눅스가 메모리를 관리하는 방법
  • 메모리 고갈 상황과 CPU 사용률을 체크하는 이유
  • 가상 메모리
  • 요구 페이징
  • I/O 장치 관리

필요한 배경 지식

  • CPU가 처리할 대상은 주기억장치 RAM 에 존재해야 한다.
    • CPU는 RAM에 있는 메모리까지만 도달할 수 있기 때문에 보조 기억장치에 있는 메모리가 필요하다면 그 메모리를 RAM에 적재해야 한다.

메모리

✔ 메모리는 주소 덩어리.
✔ 주소로 인덱싱하는 커다란 배열이다.

컴퓨터가 부팅되면, 텅텅 비어있던 메모리에 운영체제(도 메모리에 올라와 동작하는 프로세스)나 사용자 프로그램이 배열의 원소에 차곡차곡 채워지는 것처럼 CPU를 점유할 기회를 호시탐탐 노린다.

기술의 집약체인 CPU가 메모리에 채워진 프로그램 속 코드를 곧장 읽을 수 있으면 좋겠지만, 안타깝게도 CPU는 코드를 읽을 수 없다 ㅎㅎ..
컴퓨터(CPU)가 별다른 해석(컴파일) 없이 읽을 수 있는 프로그래밍 언어는 오로지 01010111010 이렇게 숫자 뿐이다. 즉 기계어로 바꿔줘야 한다.

우리가 작성한 코드를 기계어로 바꿔주는 친구가 바로 컴파일러인데, 컴파일러가 동작하는 과정에서 코드들의 주소를 결정하게 된다.

String A, String B, main() ... 와 같은 Symbolic Address들은
00, 01, ...과 같은 Logical Address(=논리주소=가상주소=CPU가 프로세스를 실행하며 보는 주소 값)로 바꿔진다. (관련 키워드: 주소 바인딩)

Symbolic Address란?
프로그래밍할 때 "메모리 몇번지에 저장해!" 라는 식으로 하는 것이 아니라
변수의 이름을 주고, 함수 역시 "몇번지로 점프해!" 라 하지 않고 함수 이름으로 호출한다.
즉 프로그래머 입장에서 숫자가 아니라 symbol로 된 주소를 사용한다.
이를 symbolic address라고 표현.
이게 컴파일 되면 0번지부터 시작되는 그 프로그램의 독자적인 Logical address로 바뀌는 것이다.

논리주소는 프로그램마다 각각 고유하다. 카카오톡 프로그램의 00~19에 있는 메모리와, 유튜브 프로그램의 00~19에 있는 메모리는 각각 다르다!

굳이 논리주소를 사용하는 이유는 뭘까?

앞서 배경지식에서 이야기한 것처럼, CPU는 논리주소만 읽을 수 있다. CPU는 현재 활동 중인 프로세스 내부의 주소만 알면 되고, 어떤 프로세스인지는 알 수도, 알 필요도 없다.

즉 어디선가 논리주소와 물리주소를 매핑해주고 있다는 것인데...

MMU

이 과정이 어떻게 가능한걸까? 운영체제가 도와줄까?
NO NO 운영체제도 메모리에 올라와 동작하는 프로세스이므로 CPU에 의해 읽히는 입장이다.
즉! 소프트웨어적으로는 물리 주소를 찾을 수 있도록 도와줄 방법이 없다.

이걸 도와주는 친구가 바로 MMU (Memory Management Unit; 메모리 관리 장치) 라는 CPU 내부에 탑재되어 있는 하드웨어이다.

사진 출처 : https://www.studytonight.com/operating-system/os-logical-and-physical-address-space

MMU의 구성
Base register : 프로그램의 시작 주소를 가짐
Limit register : 프로그램의 마지막 주소를 가짐
✔ 그리고 간단한 산술 연산기

CPU를 사용 중인 프로세스가 요청하는 논리주소에 Base register에 저장된 시작 주소를 더해서 물리주소로 변환시킨다. 이렇게 완성된 물리주소로 메모리에서 프로세스가 가진 정보를 정확히 읽을 수 있다.
이 때 Base register 작동 전 선행 동작이 필요하다. Limit register에 저장된 마지막 주소로 프로세스가 요청하는 논리주소가 올바른지 확인하는 과정이 필요하다.

정리!
✔ CPU가 프로세스를 실행할 때 사용하는 주소 값과 실제 주소 값이 다르므로 논리 주소를 물리 주소로 변환해 줘야 하는데, 이러한 동작을 하는 하드웨어 장치를 메모리 관리 장치 MMU 라고 한다.
✔ MMU는 CPU에 위치하며,
✔ CPU에서 메모리에 접근하기 전에 MMU를 거쳐 논리 주소에 해당하는 물리 주소를 얻는다.
✔ MMU는 보호해야 하는 메모리 영역에 대한 접근을 제한해 메모리를 보호하는 역할을 한다.

메모리 할당을 위한 헤딩...

다수의 프로세스를 실행하려면 한정된 메모리 공간에 많은 프로세스를 로드할 수 있어야 한다. 그래서 메모리 공간을 효율적으로 활용하기 위한 여러 방안이 고안되었었다.

근데 메모리에 프로세스를 담는 방법은 여러가지인데, 현대 운영체제에서는 배열처럼 차례대로 차곡차곡 예쁘게 채워지지 않는다.

왜 그럴까?

우선, 앞서 말한 '배열처럼 예쁘게' 채워지는 방법은 연속 메모리 할당-가변 분할 방식이다. 할당할 프로세스의 크기에 따라 메로리 공간을 분할하는 방식인데, 외부 단편화가 발생할 수 있다는 단점이 있다.

그렇다면 Swapping이라는 과정으로, 사용하지 않는 프로세스는 디스크로 Swap-out해서 메모리 공간을 얻을 수 있다. 근데 이 방법도 아래와 같은 이유 때문에 만능이라고 할 수 없다.

  • Swap-out 할 프로세스를 찾기 위해 각 프로세스의 우선순위를 고려하는 시간이 소요된다.
  • Swapping 하는 시간 자체가 오래 걸리는 작업이다.

그렇다면 Swapping기법과 함께 메모리 공간을 최대한 활용하고자,
미리 메모리를 적당히 나누는 방법 (연속 메모리 할당-고정 분할 방식)
또는,
올라오는 프로세스의 크게 맞춰 최적의 크기를 할당하는 방법이 고안되었다. (연속 메모리 할당-가변 분할 방식-최초 적합/최적 적합/최악 적합)

그럼에도 불구하고 메모리 단편화는 막을 수 없었다. (사실 완전히 막을 방법이 없다)

그래서 메모리 공간을 일정하게 잘라두고, 그에 맞춰서 프로그램을 조금씩 잘라서 올리자는 결론에 이른다. 이것이 페이징 기법이다 !!

페이징 기법의 탄생, 페이지 테이블

프로그램을 조금씩 잘라서 올리기 위해, 우선 물리 메모리를 동일한 크기로 나누었다. 이를 Frame(프레임)이라 한다. 그리고, 프로그램을 Frame과 동일한 크기로 나누고 Page(페이지)라고 칭하기로 했다.

이 Page들 중에 당장 프로그램이 동작하는 데 필요한 최소한의 Page만 메모리에 올리고, 나머지 Page들은 디스크의 Swap 영역에 저장해준다.

페이징 기법 덕분에 외부 단편화 문제는 해결되었다.
대신 메모리에 프로세스가 연속적으로 배치되지 않아서 일정한 순서가 보장되지 않았고 (마치 연결 리스트처럼..? 군데 군데 퍼져있을 수 있다)
그런 이유로 MMU의 계산도 복잡해졌다.

그래서 논리<->물리 주소 변환을 위해 별도의 Page Table(페이지 테이블)을 사용하기로 했다. 그리고 페이지 테이블 때문에 기존 MMU의 기능이나 용도도 조금씩 달라지게 되었다.

업데이트된 MMU의 구성
Page Table Base register : 페이지 테이블의 시작 주소를 가짐
Page Table Length register : 페이지 테이블의 크기를 검증하기 위함
✔ 그리고 간단한 산술 연산기는 그대로 남아있다.

CPU가 MMU한테 논리주소로 요청하게 되면, MMU 계산 이후 주소값으로 페이지 테이블에서 참조해서, 찾아낸 Frame 의 주소로 이동해 정보를 읽어온다.

페이지 테이블의 구성
✔ 프로세스의 페이지 정보
✔ 페이지에 매핑하는 프레임의 주소 값
✔ Valid 비트 : 페이지에 해당하는 프레임이 메모리에 존재하면 v(valid), 프레임이 존재하지 않거나 유효하지 않은 주소 값이면 i(invalid) 값을 반환한다.

그렇다면 이 페이지 테이블은 어디에 저장될까?

보통 페이지 테이블은 100만개 이상의 행을 가지는 것이 일반적이고,
프로세스마다 한 개씩 존재한다.

이 커다란 걸 CPU에 넣을 순 없다!

그렇다면 하드웨어에 넣을까?!
NO NO 메모리에 접근하기 위해 페이지 테이블을 사용하는 것이므로 이럴거면 처음부터 하드웨어에서 다 실행하는게 빠를지도.

남은 건 메모리(RAM)이다!

근데 메모리를 효율적으로 사용하기 위해 페이징 기법을 사용하자고 하는건데, 페이지 테이블이 메모리의 많은 공간을 차지해버리는 문제가 있다.

이 문제를 해결하기 위해 추가로 고심하기 시작한다.

그러다가 프로세스끼리 공통적으로 사용하는 부분을 생각하기 시작한다.

system.out이나 error.log 같은 부분은 메모리에 한 개씩만 올리고 프로세스가 나눠쓰게 하며 Shared Page라고 칭하기로 했다.

  • 이 녀석들은 절대 변해서는 안되겠죠! 그래서 ReadOnly 권한을 부여해야 한다.

    • 이 권한을 표시하기 위해 페이지 테이블에 Auth 공간이 추가되었지만, 훨씬 더 많은 메모리 공간을 아낄 수 있게 되었다.
  • 그리고 어떤 프로세스에서 보더라도 동일한 논리 주소를 가진다. 그래야 별도 탐색 과정 없이 바로 사용 가능할 것이다.

TLB의 도움을 받기

여기까지 메모리 부족 문제도 해결되고 뭔가 이상적인 듯 하지만...
문제가 있다.

느리다! 속도가 발목을 붙잡는다.

페이지 테이블은 메모리에 있고
페이지들도 메모리에 있다.

즉 CPU는 정보를 요청할 때마다, 메모리에 2 번이나 접근해야 한다. 느리다!

그래서 추가적인 하드웨어의 지원을 받는데, 바로 TLB (Translation Look-aside Buffers) 라는, 페이지 테이블을 보기 전에 들르는 캐시 메모리이다.
MMU 내부에 있다고 한다.

TLB는 논리 주소와 물리 주소의 변환 속도를 높여주기 위해 사용되는 캐시이다.
✔ CPU가 논리주소로 정보를 요청하면, 페이지 테이블에 접근하기 전 우선 TLB 부터 확인한다.
✔ 매칭된 주소가 있다면 TLB에 저장되어있는 프레임 주소를 바로 변환을 진행하고 메인 메모리에서 가져올 수 있다. (=메모리 1번 접근으로 끝난다)
✔ 만약 TLB에 논리주소에 매칭된 프레임주소가 없다면 그제서야 페이지 테이블을 참조한다. 이 때는 어쩔 수 없이 메모리에 2번 접근한다.

이 정도까지 온다면 현대 메모리 체계와 비슷하다.

그렇다면 OS의 역할은?

포스트의 제목부터가 리눅스 메모리 관리인데, 리눅스 OS의 역할이 이제껏 등장하지 않고 있다!

앞서 기재한 페이징 기법에서 OS는 숨겨진 기능을 하고 있었다.

  1. 가상 메모리로 사용자 프로세스 속이기
  2. I/O 장치 관리 (하드디스크 입출력 관리)

CPU를 점유 중인 프로세스는, 자신이 온전히 메모리를 점유하고 있다고 생각한다.
하지만 실제로는 메모리와 디스크 스왑영역에 나뉘어져 있는데, 이렇게 나뉘어진 걸 가상 메모리라 한다.

  • 장점은?
    • 메모리 크기의 제약으로부터 자유로워 졌기 때문에 더 많은 프로그램을 동시에 수행할 수 있다.
    • 각 프로세스는 자체의 가상 주소 공간을 갖고 있어 프로세스 간 간섭을 줄일 수 있다.
  • MMU, TLB로 주소를 변환하고 메모리에서 페이지를 찾아내는 건 사용자 프로세스와 하드웨어에서 진행하지만
  • 하드디스크같은 입출력장치를 건드리는 것은 OS의 관할이다.
    • 즉 스왑공간에서 페이지를 꺼내려면 OS의 도움이 필요하다.

Page Fault : TLB와 메모리에 없는 페이지를 요구받았을 때는 어떻게 동작할까?

  • Page Fault : 메모리에 페이지가 없다는 것을 알아차린 MMU는 프로세스를 일시정지시킨다.

  • OS가 잠에서 깨어나서 CPU를 점령하고 왜 프로세스가 멈췄는지 확인한다.

    • 만약 이상한 주소를 요청한거라면 바로 차단한다.
  • OS가 하드웨어에서 페이지를 RAM에 적재한다. 그리고 TLB에 주소를 등록해준다.

  • 페이지 테이블에도 Valid 비트는 i에서 v로 업데이트하고, Auth를 R/W로 업데이트한다.

  • OS는 CPU를 내려놓고 다시 잠에 든다.

Page Replacement : 메모리의 공간이 이미 가득한데, 새로운 페이지를 요구받았을 때는 어떻게 동작할까?

다음 Page Fault 때 하드디스크에서 페이지를 메모리에 올리기 위해 기존 메모리에 올라와 있는 페이지를 쫓아낸다.

LRU(Least Recently Used; 제일 마지막으로 참조한 녀석부터 빼는) 알고리즘이 이를 위한 방법이 제일 합리적일 것 같다.
그런데 OS는 그 정보까지는 모른다. 이미 메모리에 있었으면서 페이지폴트를 회피한 페이지 정보를 모른다. 그래서 LRU를 사용할 수 없다.

대신 Clock Algorithm 을 사용한다.

  • 메모리에 올라와있는 모든 페이지마다 1개의 reference bit을 가지도록 한다.
  • 초기에는 모두 0 이다가
  • CPU를 점유 중인 프로세스는 reference bit이 1로 변경된다
  • Page Fault 로 인해 페이지 테이블 참조 과정 중에서

이 방법은 가장 최근에 참조된 녀석은 피할 수 있는, LRU와 근사한 방법이 될 수 있다.

그리고 이 reference bit은 페이지 테이블에 추가된다.

바로 Page Replacement하기 전에, 변경 사항이 있다면 하드디스크에서 변경 사항을 적용해줘야 하므로 페이지 테이블의 dirty bit를 참고한다.

하드디스크에 변경 사항을 적용하고 페이지 테이블의 비트 정보 관리까지 모두 OS가 수행한다.

Thrashing (스레싱)

메모리가 꽉 찰수록 모든 프로세스는 페이지 교체를 하느라 바쁘고, CPU는 할 일이 없어서 쉬기 때문에 CPU 이용률이 떨어진다.
그럼 OS는 CPU의 효율을 높이기 위한다는 목적으로,, 더 많은 프로세스를 메모리에 올린다.
악순환의 반복이고 이것이 스레싱이다.

운영체제가 스레싱 해소를 위해 노력하는 방법이 있는데
바로바로 Working Set 알고리즘Page Fault Frequency 알고리즘을 사용한다.

Working Set 알고리즘

대부분 프로세스가 일정한 페이지를 집중적으로 참조한다는 지역성을 사용해서
특정 시간동안 참조되는 페이지의 개수를 파악하고, 페이지의 개수만큼 프레임이 확보되면 그제서야 페이지를 메모리에 올리는 알고리즘이다. (기다림의 미덕...)

그리고 Page Replacement가 일어날 때에도 Working Set을 기준으로 Swap out 한다.

Page Fault Frequency 알고리즘

Page Fault의 상한과 하한을 둔다.
지급하는 프레임 개수를 조절하는 방법이다.

OS가 메모리 고갈 상황과 CPU 사용률을 체크하는 이유

메모리가 고갈되면 어떤 현상이 발생할까?

  • 프로세스들의 Swap이 활발해지면서 CPU의 사용률이 하락
  • OS가 프로세스를 추가하고
  • 악순환에 의해 스레싱 발생
  • Working Set, PFF 로도 스레싱이 해소되지 않으면 Out Of Memory 상태로 판단
  • 중요도가 낮은 프로세스를 찾아 강제 종료

CPU 사용률을 계속해서 체크하는 이유는?

  • 특정 시점만 체크한 경우, CPU 사용률이 높아보일 수 있다.
  • 연속 체크 시 CPU 사용률이 급격하게 떨어지는 구간을 발견할 수도 있다.
  • 메모리 적재량을 함께 체크하면서 스레싱 유무를 확인한다.
  • 추가적인 서버 자원을 배치하는 등 해결 방안을 마련한다.

reference

profile
game client programmer

0개의 댓글