운영체제/프로세스/스레드

조정훈·2024년 7월 18일

CPU의 동작과정

cpu는 프로세스,스레드,운영체제 같은 개념을 모른다.
딱 두 가지 사항만 알고 있다

  1. 메모리에서 명령어를 가져온다
  2. 이 명령어를 실행한 후 다시 1로간다

그렇다면 cpu가 어떻게 메모리에서 명령어를 가져올까?
-> 레지스터 에서 찾을 수 있다. ( 레지스터 = PC(program counter)라고도 함 )

레지스터 = 용량은 작지만 속도는 빠른 메모리

PC 레지스터에 저장되는 것 = cpu가 다음에 실행할 주소 (메모리에 저장된 명령어 주소)

우리가 작성한 프로그램에서 시작지점(보통 main함수)에 대응하는 첫 번째 기계 명령어를 찾고 그 메모리 주소를 PC레지스터에 기록한다.

그리고 주소가 1씩 증가하면서 cpu가 차례대로 명령어를 실행한다
-> 그런데 if else나 함수를 만나면 이렇게 차례대로 실행을 못함
그래서 명령어에서 지정된 점프 할 대상 주소로 PC레지스터 값을 동적으로 변경한다

정리

cpu가 프로그램을 실행하게 하려면?
1. 실행 파일을 수동으로 메모리에 복사한다
2. main 함수에 해당하는 첫 번째 기계 명령어를 메모리에서 찾는다.
3. 그 메모리 주소를 PC레지스터에 적재한다.

하지만 이 방식은 모두 직접 해야됨.
-> 비효율적이다
ex) 멀티태스킹 안됨, 직접 드라이버 연결 필요, 네트워크 할려면 tcp/ip 연결 필요,
라이브러리 못써서 모든 함수 직접 짜야됨 등등

cpu는 하나당 하나의 프로그램의 명령어만 실행할 수 있다.
그런데 우리는 여러 프로그램을 실행 시키고 싶을 것이다. 그러면 프로그램A를 중지시켰다가 프로그램B를 실행시키고 이런식으로 전환을 빠르게 할 수 있다면 여러 프로그램이 동시에 실행되는 것 처럼 보일 것이다. (멀티태스킹)


한 프로그램을 중지시켰다가 다시 재개하려면?

cpu가 어떤 기계명령어를 실행했는지, 레지스터 값, 상태값 등의 정보를 저장할 수 있다면, 프로그램을 일시 중지해도 다음에 다시 재개할 수있다.
-> 이러한 상황정보를 context 라고한다. 이런 context가 담긴 구조체(structure)를 만들어서 코드를 작성하게 된다. 이 구조체를 프로세스 라고 부른다.

이렇게 프로그램을 메모리에 적재하고 멀티태스킹을 할 수 있게 해주는 프로세스 관리도구 같은 기능들을 모아 둔 프로그램을 "운영체제" 라고 부르기로 한다.

운영체제는 이렇게 현재 실행중인 프로세스 몇개인지, cpu몇개, 메모리 용량 몇인지, 얼마나 메모리 독점중인지 등등을 백그라운드에서 처리해준다.


프로그램 실행 중일 때 프로세스 상황

<프로세스 주소 공간>

운영체제의 가상메모리는 그림처럼 각 프로세스가 표준적인 메모리 크기를 독점적으로 사용하는 것처럼 보이게 한다.

  • 코드 영역(텍스트) : cpu가 해석 가능한 기계 명령어 코드가 저장되어있는 공간

  • 데이터 영역 : 초기화 된 전역변수 저장됨(int a = 1;). 어떤 프로그램에서 전역변수를 참조하라는 코드가 있다면, 컴파일 된 후에 데이터영역을 참조하게 된다.

  • BSS 영역(Block Started Symbol) : 초기화 되지 않은 전역변수 저장(int a;) 만약 초기화되지 않은 변수도 저장하게 된다면 큰 사이즈의 ROM이 필요해서 영역을 따로 둔다.

  • 힙 영역 : 사용자에 의해 메모리 동적 할당/해제 되는 영역. c에서는 malloc, free 로 직접 할당, 해제 해줘야함. java, c++, python 등에서는 GC로 자동 관리 가능(하지만 직접 짜는 것 보다는 최적화가 구리다).

  • 스택 영역 : 지역변수, 매개변수등 저장되는 공간. 함수 호출과 함께 할당되며, 호출 완료시 소멸된다. 재귀함수가 너무 깊게 호출되거나, 지역변수가 너무 많으면 stack overflow 에러 발생할 수 있음.



Python 에서의 메모리 관리 방법

1. 레퍼런스 카운팅(Reference counting)
-> 변수에 객체할당, 리스트나 클래스에 속성 추가, 객체를 함수의 인수로 전달하는 등의 동작을 할 때 마다 레퍼런스 카운팅 증가.
한마디로 객체가 참조될 때 마다 증가, 해제될 때 마다 감소. 0이 되면 메모리 할당 해제.

왜 가비지 컬렉션이 필요한가?

>>> a = []
>>> a.append(a)
>>> del a

이렇게 순환참조 시 여기서 a의 참조횟수는 1이지만
이 객체에는 더이상 접근 불가능하며 레퍼런스 카운팅 방식으로는 메모리에서 해제할 수 없다.

또 다른 예시

>>> a = Func_pr() # 0x01
>>> b = Func_pr() # 0x02
>>> a.x = b # 0x01의 x는 0x02를 가리킨다.
>>> b.x = a # 0x02의 x는 0x01를 가리킨다.
# 0x01의 레퍼런스 카운트는 a와 b.x로 2다.
# 0x02의 레퍼런스 카운트는 b와 a.x로 2다.
>>> del a # 0x01은 1로 감소한다. 0x02는 b와 0x01.x로 2다.
>>> del b # 0x02는 1로 감소한다.

이렇게 서로를 참조했다가 삭제하더라도 참조횟수가 1로 남아있어서 0이 될 수 없는 garbage가 된다. 이런 문제를 reference cycle(참조 주기) 라고 하며 reference counting으로는 해결이 불가능하다.


2. 세대별 가비지 컬렉션(Generational garbage collection)

이러한 순환참조 문제를 해결하기 위해 세대별로 구분하는 방식을 사용해서 관리한다.

1) generation

가비지 컬렉터(GC)는 모든 객체를 추적한다.
새로운 객체는 1세대 가비지 수집기에서 life를 시작한다. 이 세대에서 가비지 수집 프로세스를 실행하고 객체가 살아남았다면, 두번째 이전 세대로 올라간다.
python 가비지 수집기는 총 3세대이며, 객체는 현제 세대의 가비지 수집 프로세스에서 살아남을 때마다 이전 세대로 이동한다.

2) threshold

각 세대마다 가비지 컬렉터 모듈에는 임계값이 있다. 객체 수가 해당 임계값을 넘으면 가비지 콜렉션이 콜렉션 프로세스를 trigger(추적)한다.
이 프로세스에서 살아남은 객체는 이전 세대로 옮겨진다.
이렇게 내부적으로 generation, threshold로 가비지 컬렉션 주기와 객체를 관리한다.
세대는 0~2 세대로 구분되고 최근 생성된 객체는 0세대(young)에 들어가거 오래된 객체일수록 2세대(old)로 이동한다.

  • 0세대 : 가장 최근 생성된 객체. default threshold : 700 (변경가능)
  • 1세대 : 일정 시간 동안 0세대 가비지 컬렉션을 통과한 객체. default threshold : 10
  • 2세대 : 일정 시간 동안 1세대 가비지 컬렉션을 통과한 객체. default threshold : 10

한 객체는 하나의 세대에만 속하고, 가비지 컬렉터는 0세대일수록 더 자주 가비지 컬렉션을 하도록 설계되어있다.

gc는 주기적으로 0세대 객체 검사해서 가비지 수집함
-> 살아남은 객체는 1세대로 이동. 1세대 가비지 콜렉션은 0세대,1세대 모두 검사
-> 살아남은 객체는 2세대로 이동. 2세대 가비지 콜렉션은 모든 세대를 검사함
이런식으로 각 세대는 이전 세대보다 적은 빈도로 가비지 콜렉션이 실행됨.

generational hypothesis에 근거해서 가비지 컬렉션 하도록 설계하는데 이는 더 0세대에 가까운 객체 일수록 오래된 객체보다 해제될 가능성이 높다는 가설을 말한다.

작동 순서 예시)
1. 프로그램 시작되고, 여러 객체들이 생성됨
2. 객체들 중 일부는 더 이상 참조되지 않음
3. 가비지 콜렉션 실행 -> 세대별로 객체 검사 -> 사용되지 않는 객체 해제

왜 가비지 컬렉션은 성능에 영향을 주는가?

-> GC를 수행하려면 응용 프로그램을 완전 중지해야한다. 그러므로 객체가 많을수록 모든 가비지를 수집하는 데 시간이 오래걸린다.
가비지 컬렉션 주기가 짧으면 응용프로그램이 중지되는 상황이 증가하고, 반대로 주기가 길면 메모리 공간에 가비지가 많이 쌓일것이다. (시행착오 거쳐서 적정선을 찾아내서 성능 끌어올릴 필요가 있다)

import gc

# 현재 임계값 확인
print(gc.get_threshold())

# 임계값 변경 (예: 0세대 800, 1세대 20, 2세대 20)
gc.set_threshold(800, 20, 20)

# 변경된 임계값 확인
print(gc.get_threshold())

get_threshold() : 가비지 컬렉터 구성 임계값 확인
get_count() : 각 세대의 객체수 확인
gc.collect(): 수동 가비지 콜렉션 프로세스 추적 가능

등등 여러 메소드로 가비지 컬렉션을 관리할 수 있다.

threshold를 증가시키면 가비지 컬렉션이 실행되는 빈도가 줄어듬. 가비지를 오래 유지하는 cost로 프로그램에서 계산 비용이 줄어든다.
가비지컬렉션을 수동으로 관리할 수도 있지만 메모리 확보를 위해 수행하는 수동 가비지 컬렉션 프로세스는 원하지 않는 결과를 초래할 수 도 있다.

instagram 같은 경우는 가비지 콜렉터 모듈을 비활성화하는 전략을 사용해서 최적화를 시도했는데, 가비지 콜렉터가 주기적으로 실행되면서 cpu를 잡아먹고 시간이 더 걸리기 때문이다. 대규모 서비스여서 성능향상이 중요했고, 이들은 코드 구조화를 잘해서 순환 참조가 발생하지 않도록 설계했기 때문에 가비지컬렉터를 사용하지 않아도 되게끔 만들었다.
그리고 gc를 비활성화 한 대신 메모리 누수를 방지하기 위해 철저한 코드리뷰, 테스트를 통해 메모리 관리 문제를 사전 방지해서 메모리사용을 최적화시켰다.
(레퍼런스 카운팅, 메모리풀링, 맞춤 메모리 관리도구 개발 등의 방식을 통해 관리했다고 함)


참고링크 )
Python 메모리 관리 기법
메모리 구조


멀티 프로세싱

프로세스를 여러개로 나눠서 생성하여 실행한 후 여러 프로세스들의 결과값들을 합치는 방법으로 가능하다.

단점)
1. 프로세스 생성할 떄 오버헤드 크다
2. 프로세스마다 자체적인 주소공간을 갖고있어서 프로세스 간 통신은 프로그래밍하기 복잡하다.


스레드

프로세스 내에서 실행되는 더 작은 실행 단위. 하나의 프로세스는 여러 스레드를 가질 수 있으며 스레드는 프로세스의 자원을 공유한다.

스레드는 멀티 프로세싱떄 처럼 프로세스간 통신이 필요 없다.
-> 동일한 프로세스 주소 공간에 속해있어서 공유하기 때문. 모든 스레드는 변수들을 직접 사용할 수 있다.

스레드 활용 예

1. 긴 작업
ex)워드 문서편집에서 디스크에 기록저장 작업 전담 스레드 생성하는것
이 때 이 스레드의 lifecycle과 프로세스의 lifecycle은 동일하다. 종료될때 스레드 같이 종료

2. 짧은 작업
네트워크 요청, 쿼리, 등의 짧은 작업. 서버에서 많이 일어남.
이 때 작업처리에 필요한 시간이 짧고, 작업수가 많다면 어떻게 구현해야할까?
-> 단순시 스레드 생성 후 완료되면 종료. 이런식으로는 긴 작업 대상으로는 잘 작동하지만 짧은 작업에서는 장단점이 존재함.

단점)
1. 스레드 생성, 종료에 시간이 많이소요됨
2. 스레드마다 각자 독립적인 스택영역이 필요한데, 많은 스레드 생성하면 메모리, 시스템리소스 너무 많이 먹음
3. 스레드 수가 많으면 스레드 전환시 부담이 커짐

이러한 문제를 해결하기 위해 스레드풀(thread pool)이 등장.


스레드 풀

여러 개의 스레드를 생성해 놓고, 스레드가 처리할 작업이 생기면 해당 스레드에 처리를 요청하는 것.
미리 생성되어있기 때문에 스레드 생성,종료 작업이 빈번하게 일어나지 않으며, 동시에 스레드풀 내에 있는 스레드 수도 일정하게 관리되므로 메모리가 들지 않음
핵심은 스레드를 재사용 하는 것


스레드를 생성해놓고 작업들을 어떻게 스레드풀 내에있는 스레드에 전달할까?

-> 큐를 사용해서 작업들을 리스트업 해놓는다.

여기에서 task queue는 여러스레드 간에 공유되는 리소스 이므로 동기화할 때 상호배제문제(mutual exclusion in synchronization)도 처리해야한다.


스레드풀의 스레드 수를 설정하는 기준

스레드수가 너무 적으면 cpu를 최대한 활용할 수 없고, 너무 많으면 반대로 시스템성능 저하, 메모리 과다점유, 스레드 전환시 생기는 부담 등 문제가 생긴다.
적당한 개수를 어떻게 설정해야 할까?

작업들을 cpu 바운드 or i/o 바운드 작업으로 구분해야 한다.

  • cpu바운드 : 연산작업
  • i/o바운드 : 디스크, 네트워크 입출력 등에 소비하는 작업

결론은 테스트를 통해 cpu리소스, 메모리 사용 등 상황에 맞게 적절한 스레드 수를 설정해야 함

프로세스에서 스레드의 공유 리소스

코드영역, 데이터영역은 ROM (read only)에 올라가있기 때문에 내용을 변경할 수 없다.

  • 스택영역을 제외하고 모든 스레드는 영역을 공유 한다. 서로 다른 스레드의 스택영역의 변수를 변경 가능하다(다른 스레드의 스택프레임에서 포인터를 가져오면 가능).
    가상 메모리 시스템이 서로 다른 프로세스의 주소 공간의 데이터에 접근하지 못하도록 보장하지만, 스레드끼리는 이런 보호장치가 없다. 그래서 버그가 생길 수 있다.(함수 스택프레임의 손상.) 이럴 경우 오류의 원인을 찾기 힘들 수 있음.
  • 코드영역에서 어떤 함수든 모두 스레드에 적재하여 실행할 수 있다.
  • 데이터 영역에서 모든 스레드가 전역변수에 접근할 수 있다.
  • 힙영역에서 모든 스레드는 해당 변수의 포인터가 가리키는 주소의 데이터에 접근할 수 있다.

동적 링크

동적 링크에는 라이브러리코드, 데이터 포함이 안되어있어서 프로그램 시작할때 혹은 실행중에 종속된 라이브러리의 코드와 데이터를 찾아서 주소 공간에 넣는 링크과정을 해야함.
-> 프로세스 주소 공간에서 스택과 힙영역 중간에 있는 여유공간에 동적 라이브러리의 코드와 데이터를 배치할 수 있다. 이렇게 하면 프로세스 내의 모든 스레드가 동적라이브러리 코드데이터를 사용할 수 있게 된다. 이것도 스레드 간의 공유 리소스에 해당함.

스레드 전용 저장소(thread local storage)

이 저장소에 저장되는 변수는 모든 스레드에서 접근이 가능하지만 스레드에서 변수의 값을 변경하더라도 다른 스레드에는 반영되지 않는다.
어떤 한 스레드에서 독점적으로 변수를 사용하고 싶을 떄 사용


멀티 스레딩

멀티스레딩에서는 스레드 안전(thread safety)가 중요하다.

스레드 안전이란?

어떤 코드가 몇개의 스레드에서 호출되든 순서와 상관없이 올바른 결과를 낸다면 이 코드를 스레드 안전 이라고 한다. 한마디로 단일스레드, 멀티스레드 상관 없이 올바른 결과가 나와야 된다는 것이다.

그렇다면 어떻게해야 스레드에 안전한 코드를 작성할 수 있을까?
-> 전용리소스 / 공유리소스를 파악해야함

  • 전용리소스 : 함수 지역변수, 스택영역, 스레드 전용 저장소
  • 공유리소스 : 그 외

공유리소스를 사용할 떄는 순서를 따라야한다. 왜냐면 다른 작업중인 스레드를 방해하지 않도록 하기 위해.
이를위해 lock, semaphore 같은 장치를 사용할 수 있다.

  • 안전한 경우 : 지역변수사용, 함수에 매개변수를 값으로 전달(call by value), 스레드 전용 저장소의 변수 사용
    -> 전용 리소스 사용

  • 안전하지 않은 경우 : 포인터를 사용하는경우.(함수의 매개변수 포인터가 전역변수를 가리키고 있을 때) -> 공유 리소스 사용

여기서 알 수 있는 안전한 코드 작성을 위한 원칙
원칙 1. 스레드 간에 공유 리소스를 사용하지 않도록 해라

그렇다면 전역변수 사용하면 스레드 안전하지 않은건가?
-> 상황마다 다름. 이 전역변수가 한번 초기화 되고 나서 "읽기" 작업만 하면 상관 없다.

원칙 2. 전역변수 변경을 하지 마라.
변경 필요시 미리 lock 등으로 잠궈놓으면 스레드 안전 가능
포인터를 사용할 경우 안전하지 않다고했는데, 만약 스레드의 전용리소스인 지역변수 포인터를 사용할 경우는 스레드끼리 간섭이 없기 떄문에 스레드 안전이다.


코루틴

실행을 중단했다가 나중에 다시 재개할 수 있는 형태의 함수. 여러 작업을 순차적으로 처리하지 않고, 필요 시 작업을 중단하고 다른 작업을 수행할 수 있다.

원래 return을 하면 반환되면서 함수가 끝나버려서 그 이후의 코드는 실행을 못한다.(반환 된 후 프로세스 주소 공간의 스택영역에 더 이상 함수 실행정보 저장하지 않음)
하지만 코루틴이 반환될 때는 나중에 함수를 재개하기 위해 함수의 실행 시 정보를 저장 해놔야한다.
이때 보통 프로그래밍 언어에서 yield를 사용하면 일시적으로 중지시키고, 코루틴이 함수 실행 정보를 저장하기 때문에 다시 재개가 가능하다.

파이썬에서는 asyncio 모듈을 써서 코루틴 정의할 수 있다.
async def ~(): 코루틴 정의하고
await asyncio.sleep(1) 1초대기 등의 비동기 함수를 호출 할 수 있다. 이때 await 구문의 작업이 끝날떄까지 현재 코루틴의 실행을 일시 중단.
asyncio.run() 함수를 사용해서 이벤트루프를 시작할 수 있다.

코루틴의 스택영역은 힙 영역에 배치된다. 동적으로 메모리를 할당 할 수 있기 때문에 요청을 통해 일시중지, 재개가 가능하다

스택영역 : 일반함수
힙영역 : 코루틴

메모리 공간이 충분하다면 코루틴 개수는 제한이 없다.
코루틴간 전환은 사용자상태에서 일어나기 떄문에 운영체제가 개입할 필요가 없음.
이를 이용해서 비동기 프로그래밍을 할 수 있다.

0개의 댓글