OS :: Process memory

안준성·2022년 7월 21일
0

OperatingSystem

목록 보기
15/22

현대의 운영체제들은 프로세스를 실행할 때마다 프로세스 메모리 모델을 생성해 관리한다.
메모리 관리 모델에 대한 지식을 학습하고 동작 방식을 이해하면
모든 프로그래밍 언어 동작과 처리 흐름를 이해하는 데 도움이 될 것이다.

컴퓨터 시스템과 프로그램

프로그램이란 pro(= before)gram(= write)이 합쳐져 탄생한 단어로,
특정 입력에 대해 어떻게 동작할 것인지 미리 작성해놓은 것을 말한다.
image

참고할 만한 도서 : Code: The Hidden Language of Computer Hardware and Software

Compile

대부분의 언어들은 c언어의 컴파일 구조를 따른다.

  • c언어의 compile 과정
    1. 전처리 과정 : 헤더파일을 포함하고 매크로 확장
      • test.c(c언어) -> test.i
    2. Compiler : 전처리를 거친 파일을 어셈블리어로 변환
      • test.i -> test.s(어셈블리어)
    3. Assembler : 어셈블리어로 작성된 파일을 기계어로 번역
      • test.s(어셈블리어) -> test.o(기계어)
    4. Linker : 라이브러리 함수와 오브젝트 파일들을 연결
      • test.o(기계어) -> test.out(binary file)

Machine Language

프로세서가 이해하고 처리할 수 있는 2진수로 표현된 명령.

  • Mnemonic(ㄴ모닠ㅡ) code
    • 기계어를 사람이 이해하기 편리한 기호로 표시한 코드
  • Assembly language
    • Mnemoric code보다 더 이해하기 쉽도록 가상의 명령을 추가한 언어
    • ex)
      sub         esp,0E4h  
      push        ebx  
      push        esi  
      push        edi  
      lea         edi,[ebp+FFFFFF1Ch]  
      mov         ecx,39h  
      mov         eax,0CCCCCCCCh  
      rep stos    dword ptr es:[edi]  
      mov         ecx,9AC003h  
      call        009A1316  
      mov         dword ptr [ebp-8],1  
      mov         dword ptr [ebp-14h],2  
      mov         eax,dword ptr [ebp-8]  
      add         eax,dword ptr [ebp-14h]  
      mov         dword ptr [ebp-20h],eax  
      mov         eax,dword ptr [ebp-20h]

자바/코틀린의 컴파일러

컴파일의 결과로 실행파일이 아닌 .class 파일(byte code)이 만들어진다.
이 byte code는 JVM에서 실행된다.

JS Engine의 컴파일러

JS engine은 interpreter라고 많이 알려져 있지만
최근 많이 다루는 V8 engine이나 Safari에서 사용하는 JS core engine 같은 엔진들은 컴파일러에 가깝다.
약간의 차이가 있다면 JIT(Just-In-Time) compiler라고 해서 필요할 때 컴파일 시켜서 실행하는 구조로 되어있다는 점이다.
하지만 나머지 기본적인 부분은 동일하기 때문에 자바스크립트 언어를 컴파일하면 자바 컴파일러와 동일하게 byte code가 나온다.

프로그램 실행 (로딩)

디스크에 있는 실행파일(프로그램)들을 실행하면 메모리에 로딩이 된다.
이 때 프로세서가 프로세스를 생성하면서 해당 프로세스의 메모리 구조가 결정된다.


Process

실행되어 메모리에 적재된 프로그램으로,
프로세스는 각 메모리 공간을 시스템으로부터 할당 받는다.

image image

프로세스 메모리 구조 모델

image

TEXT

  • 함수코드 ,제어문, 상수 등 실행할 프로그램의 코드가 컴파일 되어 저장되는 영역.
    cpu는 이 영역에서 명령어를 하나씩 가져와서 처리.
    읽기 전용 영역이라 일반적으로 한번 로딩되면 바뀌지 않는다.

GVAR/BSS

  • 전역 (global, static) 변수가 저장되는 영역.
  • GVAR엔 0이 아닌 값으로 초기화가 된 전역 변수가 rom에,
    BSS(Block Stated Symbol)에는 초기화 되지 않은 전역변수가 ram에 저장 된다.

HEAP

  • 동적으로 할당되는 메모리 공간으로 동적 메모리 영역이라고도 부른다.
  • malloc이나 new 등으로 할당할 수 있으며, 메모리 주소 값에 의해서만 참조되고 사용되어 진다.
  • 런타임 시 크기가 결정 된다.
  • 보통 reference type이 적재되고 상황에 따라 value type도 할당 될 수 있다.(class, closure 등)

STACK

image
  • 함수를 호출할 때 마다
    지역 변수, 매개변수와 리턴값 등 잠시 사용되었다가 사라지는 데이터가 저장된다.
  • 함수 호출 시 할당되며 함수가 끝나면 반환된다.
  • value type이 할당된다.
  • 컴파일 시 크기가 결정 된다.
  • 하나의 함수에 필요한 메모리 덩어리를 묶어서 StackFrame 이라고 부른다.

Unused memory

  • 미사용 메모리 공간이다.
  • Heap이 메모리의 낮은 주소부터 할당되면 Stack은 높은 주소부터 할당된다.
  • Heap과 Stack이 서로의 영역을 침범하는 일이 발생할 수 있는데 이를 각각 Stack overflow, Heap overflow라고 한다.
    • Heap overflow: 지속적으로 메모리를 할당하고 사용 후 해제하지 않으면 Heap 영역의 버퍼가 넘쳐서 다른 버퍼를 침범하게 되고 이때 Heap overflow가 발생한다.
    • Stack overflow: 종료없이 자신을 재귀적으로 호출하는 함수를 호출하면 각 함수 호출이 새로운 Stack Frame을 생성하고, 결국 스택이 예약된 것보다(스택이 사용할 수 있는 공간보다) 더 많은 메모리를 소비하게 되어 Stack overflow가 발생한다.

참고 : 프로세스 메모리 구조
프로세스 메모리 구조 2


Swift::Stack, Heap

Stack 변수와 heap 객체가 만들어지는 흐름

다음과 같은 코드가 실행되었을 때, 프로세스 메모리의 stack과 heap 영역에서 어떠한 흐름으로 메모리가 할당 및 해제되는지 알아보자.

struct User {
    let name: String
    let age: Int
    let company: String
}

class Test {
    let testValue = 10
    let text = "반갑습니다."
    
    init() {
        hello()
    }
    
    func hello(){
        let hello = "안녕하세요."
    }
}

class MemoryExam {
    let nickname = "shark"
    let count = 10
    let cheolSuUser = User(name: "철수", age: 15, company: "구글")
    let test = Test()
    
    init() {
        run()
    }
    
    func run() {
        let youngHeeUser = User(name: "영희", age: 30, company: "애플")
        let copyCheolSuUser = cheolSuUser
        let copyTest = test
    }
}

func main() {
    let memoryExam = MemoryExam()
}

main()

맨 처음에 빈 Stack과 Heap이 있습니다.
그리고, 처음으로 main()이 실행됩니다.

func main() {
    let memoryExam = MemoryExam()
}

main()
image

그러면 실행된 main()함수와 그 내부에 있는 지역변수 memoryExam이 stack에 할당됩니다.
그 후, memoryExam에 의해 MemoryExam 클래스가 호출됩니다.

class MemoryExam {
    let nickname = "shark"
    let count = 10
    let cheolSuUser = User(name: "철수", age: 15, company: "구글")
    let test = Test()
    
    init() {
        run()
    }
    
    func run() {
        let youngHeeUser = User(name: "영희", age: 30, company: "애플")
        let copyCheolSuUser = cheolSuUser
        let copyTest = test
    }
}

image
MemoryExam 클래스가 호출되면 해당 클래스 크기만큼 Heap에 영역이 잡히는데,
nickname 1칸, count 1칸, cheolSuUser 3칸, test 1칸 으로 총 6칸의 영역이 할당됩니다.

cheolSuUser이 3칸인 이유는 User라는 struct는 내부적으로 name, age, company라는 3개의 변수를 가지고 있기 때문입니다.

여기서 struct 자체는 value type임에도 heap에 할당된 것을 볼 수 있는데,
이 경우 때문에 heap에는 reference type만 할당된다는 말은 잘못됐다는 것을 알 수 있습니다.

MemoryExam 클래스 영역이 할당될 때는 공간만 잡아주고,
MemoryExam 클래스의 코드 한줄 한줄이 실행되면서 이 할당

이 때, test에는 Test 클래스가 할당되어 새롭게 영역을 할당.

class Test {
    let testValue = 10
    let text = "반갑습니다."
    
    init() {
        hello()
    }
    
    func hello(){
        let hello = "안녕하세요."
    }
}

Test 클래스는 내부적으로 변수 2개가 있으므로 heap 영역에 2칸이 할당 되고,
MemoryExam의 test변수에 의해 Test()가 생성 됩니다.

-> Test클래스의 init() 이 호출 -> hello() 호출 -> hello 변수 호출까지 이어져 메모리는 이런 형태를 띄게 됩니다.
image

여기서 Test.init()과 Test.hello()는 call 되므로 heap이 아닌 stack에 할당되게 됩니다.
stack에는 함수의 지역변수, 매개변수, 리턴값 등이 함수가 call되면서 할당됩니다.
이 때 하나의 함수에 필요한 메모리 덩어리를 묶어 Stack Frame이라 부르고 이 전체를 통틀어 Call Stack이라 합니다.

이 후, Test.hello() 내부의 hello 변수 까지 호출되면서 Test 클래스의 호출이 종료되게 되고 stack에 할당되어 있던 Test.hello() 영역이 pop되고 Test.init() 영역까지 pop되게 됩니다.
image

Test 클래스의 호출이 종료되면서 MemoryExam의 test 변수에는 heap에 할당된 Test 영역의 주소가 저장되어 Test 영역을 바라보게(retain) 됩니다.

이제 MemoryExam의 함수를 하나하나 호출해보면

init() {
    run()
}
    
func run() {
    let youngHeeUser = User(name: "영희", age: 30, company: "애플")
    let copyCheolSuUser = cheolSuUser
    let copyTest = test
}

init() 함수가 먼저 호출되고, 그 후에 run() 함수가 호출되면서 내부 변수도 같이 할당됩니다.
물론 초기값이 세팅되는 것이 아닌 영역이 먼저 할당 되고 하나씩 실행되면서 초기값이 세팅 되는데, 여기서 cheolSuUser는 struct 이므로 copyCheolSuUser는 cheolSuUser를 바라보는게 아닌 값이 복사되어 새로 할당되게 됩니다.
image
여기서 test는 class이므로 값 타입이 아닌 참조 타입이라 copyTest는 heap의 Test영역을 retain하게 됩니다.

이제 모든 함수가 호출되었기 때문에 run() 관련 영역이 먼저 pop 되는데 이 때, copyTest가 pop 되면서 바라보던게 끊어지게(release) 됩니다.
이어 init()이 호출 종료되어 pop 되면서 MemoryExam 클래스의 호출이 종료되어 memoryExam 변수에는 heap의 MemoryExam 영역의 주소가 저장됩니다.

image

image

이 후에는 main() 함수 호출이 종료되면서 pop 되고, memoryExam이 release 되어 heap의 MemoryExam 클래스를 바라보는게 없어지게 되므로(retatin count = 0) ARC에 의해 MemoryExam 클래스도 자동으로 해제되게 됩니다.

MemoryExam이 pop되면서 test도 pop되어 release 되게 되므로 Test 클래스도 ARC에 의해 해제 됩니다.

image

결과적으로 모든 호출이 종료되어 메모리에는 아무것도 남지 않게 됩니다.


출처 : Swift 메모리의 Stack과 Heap


Fragmentation 관리 방법

heap 메모리에 메모리 할당과 해제를 자주 반복하다보면 fragmentation(단편화)이 발생할 수 있다.
이렇게 되면 메모리 공간이 충분함에도 malloc()이 실패할 수 있는데 이를 해결할 수 있는 방법을 알아본다.
image

먼저 메모리 단편화는 내부 단편화외부 단편화로 구분할 수 있는데

내부 단편화(Internal Fragmentation)**

메모리를 할당할 때 프로세스가 필요한 영역보다 더 큰 메모리 영역이 할당되어 내부적으로 메모리 영역이 낭비 되는 상황

ex)

  • 배열을 너무 크게 잡은 경우

외부 단편화(External Fragmentation)

메모리가 할당되고 해제되는게 반복되면 중간에 해제된 작은 메모리 영역이 생기게 된다.
이 때 중간의 메모리 영역이 많이 쌓이게 되면 총 메모리 공간은 충분하지만 실제로 할당은 할 수 없는 상황이 발생하게 된다.

해결 방법

Compaction(압축)

메모리 공간을 재배치하여 단편화로 인해 분산되어 있는 메모리 공간들을 하나로 합치는 기법이다.
image

Coalescing(통합)

단편화로 인해 분산된 메모리 공간들을 인접해 있는 것끼리 통합시켜 큰 메모리 공간으로 합치는 기법이다.
압축은 재배치가 일어나지만 통합은 인접한 공간들끼리 합친다는 차이가 있다.
image

Paging

  • Page
    가상 메모리를 같은 크기의 블록으로 나눈 것

  • Frame
    주 기억장치를 페이지와 같은 크기로 나눈 것

페이징 기법이란 사용하지 않는 프레임을 페이지로 옮기고(swap-out),
필요한 메모미를 페이지 단위로 프레임에 옮기는(swap-int) 기법이다.
외부 단편화는 해결할 수 있지만 내부 단편화는 해결하지 못한다.

Segmentation

페이징 기법과 유사하지만 일정한 크기가 아닌 서로 다른 크기로 나눈 Segmanet를 활용한다.
내부 단편화는 해결할 수 있지만 외부 단편화는 해결하지 못한다.

Memory Pool

동적할당과 비슷한 개념이지만 필요한 메모리 공간을 필요한 크기, 갯수만큼 사용자가 직접 지정하여 미리 할당 받아 놓는다는 점에서 차이가 있다.
미리 할당받은 메모리를 memory pool에 모아놓고 필요할 때마다 사용하고 반납하는 방식이다.
image

메모리 풀 없이 메모리 동적 할당 및 해제를 반복하면 랜덤한 위치에 할당과 해제가 반복되어 단편화를 일으킬 수 있지만 미리 공간을 할당해 놓는다면 할당받았던 메모리 공간을 사용하고 반납하기 때문에 메모리 할당과 해제로 인한 외부 단편화가 발생하지 않는다.
또한 필요한 크기만큼 할당해놓기 때문에 내부 단편화도 생기지 않는다.

하지만 미리 할당해 놓고 사용하지 않는 메모리 누수가 발생할 수 있어 잦은 동적할당과 해제가 일어나는 상황에서 사용하는 것이 효과적이다.

  • 구현 방법
    1. 큰 메모리 블록을 할당 받는다.
    2. 할당 받은 페이지를 각 객체의 크기의 블록으로 나눈다.
    3. 각 객체마다 블록을 링크한다.
    4. 이 때, 할당할 블록을 특정 포인터가 가리키게 한다.
    5. 실제 메모리 할당이 필요할 때 헤더 포인터가 가리키는 블록을 반환해준다.
    6. 헤더 포인터는 그 다음 블록을 가리킨다.
    7. 사용이 끝난 메모리가 반납될 경우 반납된 블록의 포인터를 현재 헤더 포인터가 가리키는 블록을 가리키게 하고 헤더 포인터는 반납된 블록을 가리킨다.

참조 : 메모리 단편화에 대한 해결 방법


iOS에서의 프로세스 메모리 관리

iOS에서는 힙 영역에 올라가있는 참조 타입의 메모리를 관리하기 위해 retain count라는 방식을 사용한다.
retain이란 객체에 대한 소유권을 나타내며 malloc이나 new, copy와 같은 명령어를 통해
객체의 소유권을 받을 수 있다.
해당 객체에 대한 메모리는 최초 생성시 힙에 적재되었다가 retain count가 0이 되면 해제 된다.

이러한 방식은 가비지 컬렉팅 없이도 힙 메모리를 관리할 수 있다는 장점이 있다.

ARC(Automatic Reference Counting)

ARC란 컴파일 시 코드를 분석해서 자동으로 retain, release 코드를 생성해주고, 참조된 횟수를 추적해 더 이상 참조되지 않는 인스턴스를 메모리에서 해제해주는 Clang 컴파일러의 메모리 관리 기능입니다.

메모리 참조 방법

strong

  • 강한참조
    해당 인스턴스의 소유권을 가지며 자신이 참조하는 인스턴스의 retain count를 증가 시킨다.
    선언할 때 아무것도 적어주지 않으면 default로 strong이 선언된다.
var test = Test() // strong var test = Test(). retain count 1 증가
test = nil // retain count가 1 감소되어 0이 되면서 메모리 해제.

weak

  • 약한 참조
    해당 인스턴스의 소유권을 가지지 않으며 자신이 참조하는 인스턴스가 nil이 될 수 있게 열어둠으로써 retain count를 증가시키지 않는다.
weak var test = Test() // 객체가 생성되지만 weak이기 때문에 바로 해제되어 nil이 된다.

이 때 weak var로 선언하려는 프로퍼티의 자료형은 반드시 Optional 자료형이어야 합니다.
또한 이렇게 Optional 자료형이라는 점에서 let이 아닌 var로만 가능합니다.

unowned

  • 약한 참조
    해당 인스턴스의 소유권을 가지지 않으며 reference counting을 하지말라고 선언하는 것이다.
    weak과 달리 아예 reference counting을 하지 않는다.
unowned let test = Test() // 객체 생성과 동시에 해제되고 댕글링 포인터만 가지고 있다.
  • 댕글링 포인터(Dangling Pointer)
    원래 바라보던 객체가 해제되어 할당되지 않은 공간을 바라보는 포인터
    premature free(조숙한 해제)라고 부르기도 한다.

weak과 unowned의 차이점

  • weak는 객체를 계속 추적하면서 객체가 사라지게 되면 nil로 바꾸지만
    unowned는 객체가 사라지면 댕글링 포인터가 남습니다.
    이 때 댕글링 포인터를 참조하면 crash가 나게 되는데, 이 때문에 unowned는 Optional이 아닌, 사라지지 않을 거라고 보장되는 객체에만 설정해야 합니다.
    따라서 var가 아닌 let으로 선언합니다.

이러한 위험성에도 unowned가 존재하는 이유는
weak는 객체를 계속 추적하기 때문에 unowned보다 오버헤드가 커
해제되지 않을게 확실한 객체에는 unowned를 사용하는 것입니다.

순환 참조

ARC가 편하게 메모리를 관리해주지만 잘못하면 순환참조가 발생할 수 있습니다.
여기서 순환참조란 서로가 서로를 소유하고 있어 절대 메모리가 해제 되지 않는 것을 의미합니다.

image

위 그림과 같이 서로가 서로를 참조하여 reference count가 1에서 줄어들지 않아 ARC에 의해 영원히 해제되지 않는 상황이 발생할 수 있습니다.

이러한 문제는 weak과 unowned를 통해 해결할 수 있는데
먼저 양쪽 모두 nil이 될 수 있는 경우 weak을 사용합니다.
반대로 어느 한쪽이 절대로 nil이 될 수 없는 경우 unowned를 사용합니다.

이처럼 둘 중 하나에 weak이나 unowned를 붙이면 한쪽의 소유관계가 깨어져 순환참조를 방지할 수 있습니다.


참고 : iOS::ARC, strong, weak, unowned
Swift3:메모리 관리


iOS에서의 가상 메모리 관리 동작 방식

image

가상 메모리란?

  • 메모리 관리 기법 중 하나로, 각 프로그램에 실제 메모리 주소가 아닌 가상 메모리 주소를 주는 방식을 말한다.
    가상 메모리를 이용하면 실제 물리 메모리에 구애받지 않고 논리적으로 메모리를 사용 할 수 있다.
    가상 메모리 주소를 실제 물리 메모리 주소로 mapping하는 작업은 CPU의 MMU(memory management unit)에 의해 처리된다.

iOS에서 메모리는 크게 클린 메모리, 더티 메모리, 압축된 메모리로 분류된다.

  • 클린 메모리는 디스크에서 로드될 수 있으며, 프레임 워크와 실행 코드 및 읽기 전용 파일이 있다.
  • 더티 메모리는 앱, 힙, 스택, 싱글톤, 글로벌 이니셜라이저 등으로 채워져 있다.
  • 압축된 메모리는 할당은 되었으나 액세스 되지 않은 페이지가 압축되어 있는 것을 말한다.

특히 압축된 메모리는 캐시와 깊은 연관이 있다.
캐시 공간을 좀 더 효율적으로 사용하기 위해 사용하지 않은 페이지를 압축하는 것이다.


참조 : WWDC 2018::iOS Memory Deep Dive
WWDC 2018::iOS Memory Deep Dive 정리

profile
안녕하세요 준갑습니성

0개의 댓글