1장. 프로그래밍 언어, 컴파일러, 링커
기본적으로 cpu는 0101 2진수로 이루어진 insturction을 받고 계산을 수행하는 하드웨어입니다.
다만 그 속도가 빨라 유용하게 사용할 수 있는 것 입니다.
컴파일러
태초에는 0101로 이루어진 2진수로 프로그램을 작성했지만 한계에 봉착하여 어셈블리어가 등장하게 됩니다.
해당 어셈블리어도 여전히 저수준 언어이므로 인간이 원하는 분기처리나 반복을 작성하기는 쉽지 않았습니다.
그래서 인간의 추상적인 표현을 cpu가 이해할 수 있는 저수준 언어로 표현하기 위해서 고수준 언어(C, Python)와 고수준 언어를 저수준 언어로 번역 해 주는 컴파일러가 등장하게 됩니다.
컴파일러에서는 우리가 작성한 코드를 잘게 쪼개서 토큰으로 분석합니다. 이 과정을 어휘 분석(lexical analysis)라고 합니다.
토큰을 분석한 뒤 고수준 언어의 문법에 맞게 토큰들이 구성되어 있는 해석하여 구문 트리를 구성하는데 이를 구문 분석 이라고 합니다.
이후 구문 트리에 이상이 없는지 확인하는 과정을 의미 분석(semantic analysis)라고 합니다.
구문 트리를 보고나서, 계산이론(COSE215)수업에서 배운 parse tree가 떠올랐습니다.
아직 프로그래밍 언어 수업은 듣지 않아서 lexical, semantic analysis가 뭔지 정확히는 모르겠으나 오토마타 이론과 CNF에서 어렴풋이 다룬 내용이 생각나 재미있었습니다.
Ambiguious 하지 않게 parsing하는 게 중요하다고 말씀 해 주셨었는데, 더 공부해봐야겠다는 생각을 했습니다.
구문트리 이후 중간 코드(IRCode)를 생성한 뒤 어셈블리어 코드로 변환합니다. 책에서는 이 부분은 그냥 넘어가서 왜 이 과정이 필요한가 검색을 해 보니 IRCode로 변환하여 다양한 언어를 다중 타겟(arm, x86)에서 돌아갈 수 있는 코드를 작성하기 위함이고, 이 과정에서 최적화가 많이 이루어진다고 합니다.
object file과는 별도의 개념으로 이해 했습니다.
링커
어셈블리어 코드로 컴파일 된 이후에 링커가 이제 등장합니다. 링커는 컴파일러가 생성한 대상 파일 여러개를 묶어서 하나의 실행 파일을 생성 합니다.
Windows 에서 exe 같은 개념이라고 할 수 있습니다.
링커는 크게 세가지 중요한 일을 수행하는데 심볼 해석(symbol resolution), 실행파일 생성, 재배치(relocation)을 수행합니다.
컴파일 단계에서 소스 코드에 외부에서 정의된 전역변수나 함수(심볼)를 사용했거나 반대로 외부에서 참조 가능한 심볼이 있으면 이를 심볼 테이블에 기록합니다.
링커는 이 심볼테이블을 해석해서 유일한 정의가 발견 가능한지 확인합니다.
동적 링크와 정적 링크
어떤 자주 사용되는 함수를 미리 컴파일 해 두고 이를 라이브러리로 사용할 때에 실행파일에 함께 포함시키는 일도 링커가 담당합니다.
“포함“시킨다는 표현에 이견이 있을 수도 있는데, 이는 정적 링크와 동적 링크 때문일 겁니다.
정적 링크는 정적 라이브러리를 실행 파일에 복사하여 참조할 수 있게 하지만,
동적 링크는 동적 라이브러리 이름, 심볼 테이블, 재배치 정보들만 실행파일에 포함시킵니다.
동적 링크에는 또 두가지 방식이 있는데,
프로그램이 메모리에 loading 될 때 같이 링킹을 하는 방식과,
프로그램 실행 중(runtime)에서 링킹을 하는 방식이 있습니다.
정적 링킹과 동적 링킹은 장단이 있어서 상황에 맞게 잘 활용해야 합니다.
동적 라이브러리는 버그수정이나 코드 재사용적인 면, 프로그램 확장성 면에서 뛰어나지만 성능에서 정적 라이브러리보다는 떨어집니다.
또, 종석된 동적 라이브러리 문제로 프로그램 설치와 배포에 문제를 겪을 수 있습니다.
마지막 단점 때문에 스타트업에서 프로그램 배포 과정에서 겪었던 문제가 떠올랐습니다.
배달 플랫폼으로 시작했었기에, pos 기기에 프로그램을 돌려야 하는 일이 있었는데, 그때 C# + webview + webserial API를 이용해서 개발을 했었습니다.
하지만 windows 10 환경에서 잘 돌아가던 프로그램이 막상 pos 기기에 설치하니 제대로 실행되지 않는 문제가 있었습니다.
pos기기도 오래되고 사양이 떨어지는 것도 문제였지만, .net framework가 제대로 깔려있지 않았던 pos 기기도 많았고, windows webview 특성상 edge webview framework 도 있었던걸로 기억합니다.
해결하기위해 chrominum을 프로그램 자체에 포함시키고, .net 4.5.x 버전을 함께 설치하도록 안내하니 얼추 해결되었던 경험이 있습니다.
다만, 이렇게 하다 보니 프로그램 자체의 용량이 커져서 프로그램 자체를 설치하지 못하는 pos 기기도 있었다는게…
배포환경이 열악해서 참 어려운 문제였던게 기억에 많이 남습니다.
여담으로, 요기요나 배민은 어떻게 하나 봤더니, 요기요는 .net 2.7?버전정도 엄청 오래된 버전을 쓰고 있었던걸로 기억합니다.. :(
재배치, relocation
아무튼, 이렇게 외부 심볼을 링킹 한 후, 심볼을 호출 할 때 call ~~ 이런식으로 호출을 하는데 ~~에 해당하는 주소를 다시 변경 해 줍니다.
이 과정이 바로 relocation입니다.
이 장에서 프로그램이 실행된 뒤 메모리가 가상 메모리 영역에서 높은주소에서 낮은주소 순으로 [커널, 스택, ~~, 힙, 데이터, 코드] 이렇게 되어있다는 사실을 함께 설명 해 줍니다. (사실 커널 space은 page table을 이용해 매핑되어있음, 때문에 프로세스가 커널 space를 공유해서 사용할 수 있음)
25년도 1학기에 운영체제를 들었던 기억이 새록새록 떠올랐는데, page table, MMU, TLB 개념에 대해 알고 있다면 더 이해가 쉬울 것 같습니다.
2장. 프로세스, 스레드, 코루틴, 동기/비동기
프로세스에 대해 이해하려면 cpu의 동작 방식에서부터 시작하면 됩니다.
cpu는 기본적으로 한번에 하나의 instruction을 수행합니다(pipelining, multi core 는 넘어갈게요)
때문에 하나의 프로그램을 처리할 수 있습니다.
여러개의 프로그램을 처리하기 위해 등장한 개념이 프로세스라고 할 수 있습니다. 멀티 프로그래밍의 등장입니다.
프로세스마다 상태(program counter, register)를 가지고 있을 겁니다. 이를 context라 보통 부르며, 해당 context를 저장하는 PCB라는 객체를 이용해서 PCB를 저장 후 프로세스를 오가면서 마치 여러개의 프로그램이 동시에 돌아가는 것 처럼 보이게 합니다.
이때 switching하는 과정을 context switch라고 합니다.
Thread, 스레드
이렇게 process를 여러개 생성하는 것에는 한계가 있는데, 프로세스 생성 시(fork) overhead가 있고, 프로세스간 통신(IPC)가 어렵다는 점 입니다.
때문에 하나의 process에서 여러개의 실행 흐름을 가지게 하기 위한 것이 thread입니다.
thread는 cpu 실행의 최소 단위라고 볼 수도 있습니다.
thread는 기본적으로 동일한 프로세스 주소 공간을 차지합니다.
또, 각기 다른 실행 흐름을 가지기에 cpu의 다른 코어에서 실행 가능합니다.
하지만 동일한 프로세스 주소 공간을 사용 한다는 점이 장점이기도 하지만 어려움으로 다가올 수도 있는것이,
deadlock 때문입니다. 스레드가 공유 리소스에 접근할 때 다른 스레드를 고려하지 않기 때문에 생기는 문제입니다.
이 문제를 mutual exclusion과 synchronization으로 명시적으로 해결해야 합니다.
작업을 처리할 때마다 스레드를 생성하고, 끝나면 삭제하는게 좋은 방법일까요?
생성과 삭제에 overhead가 발생하며, 여러개의 스레드가 생성되었을 때 메모리 부담이 생길 수 있습니다.
그래서 등장한 개념이 thread pool 입니다.
thread pool은 생산자 스레드와 소비자 스레드, 작업 queue 세개의 큰 구조로 이루어집니다.
생산자 스레드에서 작업 queue에 작업을 enqueue합니다. 소비자 스레드에서는 적절한 순간에 dequeue하며 해당 작업을 수행합니다.
스레드 풀의 스레드 수는 대략 N개의 코어를 가진 시스템에서 N * (1 + WT(IO대기시간) / CT(CPU연산시간)) 입니다.
하지만 WT와 CT를 적절하게 계산이 어려우므로 상황에 맞게 경험적으로 처리해야 합니다.
스레드 안전 코드
어떤 코드가 주어졌을 때 그 코드가 스레드 몇개에서, 어떤 순서로든 호출되든 올바른 결과가 나오는 코드를 얘기합니다.
thread는 stack을 제외한 공간을 공유하는데, 이 때문에 예상치 못한 결과를 낼 수 있습니다.
때문에 __thread
라는 keyword를 이용하여 스레드 전용 저장소를 사용할 수 있습니다.
해당 변수는 스레드 전용 저장소에 복사되기때문에 값을 변경해도 다른 스레드에 영향을 미치지 않습니다.
__thread
키워드 이외의 방법으로는 atomic operation
을 이용하거나 보호하거나 mutual exclusion을 고려하여 lock
이나 semaphore
, spin lock
같은 방법으로 스레드 안전 코드를 작성 할 수 있습니다.
코루틴
하나의 실행 흐름에서 thread와 비슷한 효과를 내기 위해서 사용하는 게 코루틴입니다.
코루틴은 일반 함수와 비슷한데, 자신이 마지막으로 실행된 위치를 기억하고 있습니다.
yield
같은 키워드를 이용하여 CPU가 block되지 않고 리소스를 다른 작업을 하는 데에 쓸 수 있습니다.
다른 작업을 한 뒤에 다시 호출하여 해당 함수를 이어나갈 수 있습니다.
코루틴은 CPU의 레지스터 정보, 함수 실행 시 상태 정보를 담고 있어야 하는데, 이는 프로세스의 힙 영역에 배치됩니다.
또 다른 실행 흐름을 보존할 수 있다는 사실을 알 수 있습니다.
동기와 비동기, 블로킹과 논블로킹
동기 호출은 함수를 호출했을 때, 함수값을 리턴할 때까지 기다리는 방식입니다.
반면 비동기는 함수값을 리턴하는거랑 별개로, 함수를 실행 후에 다음 명령어를 계속 실행하는 것 입니다.
만약 함수를 호출하는 데에 cost가 크다면 비동기를 고려해 볼 수 있겠죠.
콜백
만약 함수 호출 후 리턴값에 다음 명령어가 종속적이라면 어떡할까요?
다음 동기 호출만이 답일까요?
이에 대한 문제를 해결하기 위해 콜백 함수라는 개념이 생겼습니다.
ABC를 행하는 함수 호출 후 DEF라는 작업을 처리해야 한다고 가정 할 때, DEF라는 작업을 처리하는 함수를 콜백으로 넘겨준 뒤 비동기 호출로 처리하면, ABCDEF가 순차적으로 처리될 수 있지만 사실은 비동기로 처리가 가능합니다.
만약 메인 스레드 내내 함수의 호출 결과가 쓰인다면요?
그러면 event 방식으로 처리하거나 알림을 주는 방식으로 비동기를 처리할 수 있겠습니다.
블로킹과 논블로킹은 특정 함수 A가 함수 B를 호출할 때, B를 호출함과 동시에 운영체제가 함수 A의 스레드나 프로세스를 일시 중지 시킨다면 블로킹, 아니라면 논 블로킹 방식입니다.
대표적으로 IO 처리를 할 때 블로킹과 관련이 있습니다. IO에 비해 CPU가 워낙 빠르기 때문입니다.
이를 처리하기 위해서 CPU에서 인터럽트를 이용하거나 polling 방식을 사용할 수 있습니다.
만약 운영체제가 정지하는 게 아니여도, polling 방식을 이용 중이라면 블로킹이라고 할 수 있습니다.
이벤트 기반 프로그래밍
event와 event handler 2개로 주로 구성됩니다. 이벤트가 발생하면 이벤트를 수신하여 적절한 handler에게 넘겨주어 처리 할 수 있습니다.
Network 요청이 들어오는것도 event, 사용자의 키보드, 마우스 클릭 다 event라고 볼 수 있습니다.
리눅스에서는 epoll로 하나의 스레드에서 이벤트를 순환하면서 입출력을 다중화할 수 있습니다.
보통은 이벤트 순환 스레드에서는 논블로킹 작업을, 이벤트 핸들러(작업자 스레드)에서는 블로킹 작업을 수행 합니다.
코루틴을 이용하면 동기 방식으로 프로그래밍 하더라도, 비동기 실행과 비슷한 효과를 얻을 수 있습니다.
이벤트 순환 스레드에서 코루틴을 생성하고 작업자 스레드에서 준비된 코루틴들을 실행하는 방식입니다.