<프로세스의 생성과 소멸>
[프로세스의 이해]
현재 운영체제는 "멀티 프로세스(Multi-Process) 운영체제"라고 한다.
쉽게 말하면 "프로세스라는 것이 여러개 존재할 수 있는 운영체제"를 말한다.
여기서 프로세스라는 것을 뭘 말하는 것일까?
애초에 프로그램은 뭐고 프로세스는 뭘까?
[프로세스(Process)란 무엇인가?]
어려운 개념이 아니다.
쉽게 말할 것도 없다.
"프로세스"란, 메인 메모리에 올라가서 현재 실행 중에 있는 프로그램을 의미한다.
쉽게 생각해서 우리가 즐겨하는 게임들을 예로 들면 금방 이해가 될 것 같다.
원신이라던가, 리그 오브 레전드 같은 게임을 실행할 때, 이 실행 파일의 이름이 "게임이름.exe"같은 식으로 되어 있다.
이런 실행 파일을 프로그램이라고 한다.
그리고 이 프로그램을 실행하기 위해서 더블클릭을 하면?
그 순간부터 프로그램을 실행하기 위해서 메모리의 할당이 이뤄지고, 이 메모리 공간으로 바이너리 코드가 올라간다.
이때부터 프로그램은 프로세스라고 부를 수 있는 것이다.
[프로세스를 구성하는 요소(1) - 메모리 구조(C를 기준으로)]
일단 이 책을 공부하고 계신다는 이야기는 못해도 C나 C++ 책은 한 권은 떼고 오셨을 것이라 생각된다.
그래도 모를 수도 있는 것이고 나도 모르는 부분이 있어서 메모리 영역에 대한 추가 설명을 좀 하고 가겠다.
세 그림 중 첫 번째 그림은 대부분이 알고 있는 그림이다.
그런데 두 번째 그림부터는 이게 뭔가 싶으실 분들이 있으실거다.
나도 그랬다.
https://blog.naver.com/justkukaro/220681279377
여기를 참고하면서 정리가 굉장히 많이 되었다.
혹시라도 관심 있으신 분들은 여기서 정리한 내용을 보셔도 좋을 것 같다.
아마 아시는 분들은 아실테지만 프로세스(또는 프로그램)의 메모리 영역에 대해서 세부적으로 정리하려고 한다.
알아둬서 해가 되는 것은 없으므로 위에서부터 차근차근 정리해보겠다.
우선 영역별로 정리하기에 앞서서 운영체제에서는 실행파일을 실행하면 어떤 순서로 메모리를 올리는가?
1. 실행 파일 내의 Code 영역(바이너리 코드)이 가장 먼저 올라간다.
2. 컴파일 시간에 결정되는 변수들(전역변수, 상수)인 Data 영역이 메모리에 올라간다.
3. 이후 나머지 변수들은 프로그램이 실행되는 시간 동안 생성되며 올라간다.
지역 변수는 Stack 영역에, 동적 할당으로 만들어지는 변수는 Heap 영역에.
위의 설명을 따라가면 두 번째 그림을 기준으로 낮은 주소에서부터 높은 주소값 순서대로 메모리에 올라가게 된다.
그런데 여기서 Stack만 예외적으로 높은 주소에서 낮은 주소 순으로 올라간다.
그 이유는? Stack과 Heap 영역을 구분하기 위해서다.
그림에서도 보면 Stack과 Heap 사이의 간극이 있는 것을 볼 수 있다.
만약 Stack도 똑같이 낮은 주소에서 높은 주소 순으로 올라가게 되면 Stack과 Heap 영역을 구분할 수 없게 된다.
그리고 영역 관련해서 이야기를 더 해나가면 Overflow 이야기까지 가게 된다.
여기까지 가면 설명이 한도 끝도 없어지므로 관심있으신 분들은 따로 찾아서 보시면 된다.
이제 메모리 영역별로 어떤 데이터들이 들어가는지 정리해보겠다.
1) Code 영역 - init, text, rodata
우선 Code 영역은 흔히 알고 있듯이 절대로 바꿀 수 없는 영역이다.
(간혹보면 TEXT 영역이나 영역을 세그먼트라고 표현하는 곳도 있는데, 여기서는 그냥 코드 영역이라고 합시다.)
우리가 작성한 코드를 실행파일로 만들면 실행파일을 구성하는 명령어(바이너리 코드)들이 올라가는 영역이다.
말 그대로 작성한 코드가 그 자체가 되기 때문에 읽을 수는 있어도 수정은 불가능하다.
만약 수정이 가능하다면? 프로그램 자체가 바뀔 수 있는 것이다.
그래서 Code 영역은 읽기 전용(Read Only)이라는 것을 기억해두시면 좋겠다.
근데 여기서 또 영역이 셋으로 나뉘는데, 다음과 같다.
1-1) text 영역
코드 그 자체가 기록되는 영역.
C언어로 코드를 작성하면서 선언한 함수들 전부가 이 곳에 기록이 된다.
어차피 건드려야 할 영역이 아니므로, 선언한 함수들이 저장되는 영역이라는 것만 포인트로 알아두자.
1-2) init 영역
또는 const 영역이라고도 한다.
const가 의미하는 것은 알다시피 상수(constant)라는 뜻이고, init은 초기화(initialize)를 줄여서 표현한 것이다.
뭐가 어찌되었건 이 영역은 Code 영역에 속해있으므로 읽기 전용이다.
그 말인즉슨, 저장되는 값은 절대로 변경할 수 없는 "리터럴(literal)"이 기록이 되는 곳이다.
그리고 더 엄밀히 따지면 상수들 중에서도 "선언과 동시에 초기화에 사용된 리터럴"이 기록되는 곳이다.
1-3) rodata 영역
ro는 read only를 줄인 것으로 역시나 읽기 전용 영역이다.
어떻게 보면 위의 init 영역과 별 차이가 없다고 생각할 수도 있다.
그런데 init 영역은 "선언과 동시에 초기화에 사용된 리터럴"이 기록되는 곳이라고 했다.
그럼 여기는? 그렇지 않은, 선언과 동시에 초기화에 사용되지 않은 리터럴들이 모두 저장되는 곳이다.
2) Data 영역 - data, bss
여기는 위에서도 적혀있는 것처럼 전역 변수 또는 static 변수들이 저장이 되는 곳이다.
그래서 프로그램이 종료될 때까지 계속 남아있는 변수들이 이 곳에 저장된다고 보시면 되겠다.
Code 영역과는 다르게 읽고 쓰는 것 (Read/Write)이 가능한 변수다.
그런데 여기서도 또 data와 bss영역이 나뉜다.
static 변수와 전역 변수가 저장된다고 해서 다 똑같이 저장되지 않는다는 것이다.
2-1) data 영역
static 변수와 전역 변수 중에서도 "초기화가 된 변수"가 이 곳에 저장이 된다.
그러니까 선언과 동시에 초기화가 이뤄진 static 변수와 전역 변수가 이 영역에 저장이 된다.
2-2) bss 영역
bss는 Block Started by Symbol의 줄임말이라고 한다.
그냥 쉽게 말하면 static 변수와 전역 변수를 선언만 해놓고 초기화를 하지 않은 변수들이 이 영역에 저장이 된다.
그래서 static 변수나 전역 변수를 초기화를 하지 않으면 0부터 초기화가 자동으로 이뤄진다.
그 이유가 bss 영역에 있기 때문에 자동으로 0부터 초기화를 시켜주기 때문이다.
3) Stack 영역
우리가 흔히 알고 있는 지역 변수(혹은 자동변수)가 이곳에 저장된다.
그래서 프로그램이 실행되는 동안 한 블록 내에서 생성된 변수는 Stack 영역에 저장되고 블록이 끝나면 소멸된다.
그리고 좀 어려운 말일 수도 있겠지만, 지역 변수를 선언하는 것을 '정적 바인딩' 또는 '메모리 정적 할당'이라고 한다.
왜냐하면, 지역 변수는 컴파일을 하면서 변수의 형태(자료형)이 결정이 되기 때문이다.
4) Heap 영역
C 기준으로는 malloc, C++ 기준으로는 new에 의해 메모리를 동적 할당하면 이 곳에 저장이 된다.
위의 Stack 영역도 그렇고 Heap 영역도 그렇듯 변수는 실행되는 동안에 메모리 공간에 생성되고 소멸된다.
Stack은 '정적 바인딩', '메모리 정적 할당'이라는 말을 썼는데, 여기서는 '메모리 동적 할당'이라고 한다.
Stack이나 Heap은 모두 프로그램이 실행되면서 저장이 되는 것은 동일하다.
하지만 Stack과 달리 Heap에 저장되는 변수는 프로그램이 실행되는 동안에 자료형이 결정된다.
그래서 '동적 바인딩', '메모리 동적 할당'이라고 하는 것이다.
메모리 영역에 대한 설명을 따로 떼어놓을까 하다가 그냥 같이 붙여뒀다.
다시 본론으로 돌아오면, 이 메모리 구조는 프로그램 실행 시에 만들어지는 메모리 공간의 구성이 된다.
그러니까 프로세스가 되면 저런 메모리 구조가 만들어 진다는 것이다.
이것이 프로세스의 실체다.
프로세스 생성 시 만들어지는 메모리 구조를 프로세스라고 하는 경우도 있다.
실제로 프로그램 실행을 위해서 명령어들이 메모리 공간에 올라와 있고 필요한 공간도 할당이 되어있기 때문이다.
그래서 내가 실행시킨 프로그램이 3개라고 치면 메모리 구조는 몇 개가 구성 될까?
프로그램이 3개가 실행되었으니 프로세스 역시 3개이므로 메모리 구조도 3개가 구성이 된다.
[프로세스를 구성하는 요소(2) - Register Set]
프로세스를 구성하는 요소에 레지스터는 갑자기 왜?
얘는 CPU에 있는 구성요소가 아닌가 하실텐데, 좀 더 넓게 생각해볼 필요가 있다.
프로그램이 실행이 되었다는 말은 CPU에 의해 명령어를 수행한다는 말이다.
더 나아가면, 레지스터를 무조건 사용하게 된다는 말이므로 절대적으로 필요한 존재가 된다.
그래서 프로그램이 프로세스가 되면, CPU내의 레지스터들은 지금 실행 중인 프로세스의 데이터로 가득 채워진다.
그래서 레지스터들(Register Set)의 상태도 프로세스의 일부로 포함시킬 수 있다.
그리고 이 개념은 추후에 다루게 될 컨텍스트 스위칭(Context Switching, 문맥 교환)과 연관이 있다.
[프로세스의 스케줄링과 상태 변화]
실제로 컴퓨터에 붙어있는 CPU는 하나다.
그런데 CPU는 하나인데 어떻게 여러 개의 프로세스가 실행이 되는가?
원래 기본적으로는 CPU는 한 순간에 하나의 프로그램만 실행이 가능하다.
동시에 둘 이상의 프로그램을 실행시킬 수 없다는 뜻이다.
[프로세스의 스케줄링(Scheduling)]
그래서 어떻게 안되는 것을 가능하게 했을까?
CPU는 굉장히 빠르게 연산을 처리한다.
그래서 하나의 CPU가 여러 프로세스를 고속으로 번갈아가면서 실행하면?
컴퓨터를 사용하는 우리 입장에서는 CPU가 동시에 여러 프로그램을 실행시킨다고 느끼게 된다.
결론은 여러 프로세스가 실행되는 것처럼 보이는 것은 여러 프로세스들이 CPU의 할당 시간을 나눠서 쓰기 때문이다.
[스케줄링의 기본 원리]
그럼 프로세스들에게 CPU를 할당하는 기준은 뭘까?
일단 A, B, C라는 프로세스 3개가 동시에 실행되어야 한다 치자.
어떻게 CPU를 할당해야 셋 다 돌아가는 것처럼 보일 수 있을까?
이처럼 프로세스의 CPU 할당 순서와 방법을 결정하는 것을 가리켜 스케줄링(Scheduling)이라고 한다.
그리고 이 스케줄링에 사용되는 알고리즘을 스케줄링 알고리즘(Scheduling Algorithms)라고 한다.
그리고 이 알고리즘을 적용해서 실제로 프로세스를 관리하는 운영체제의 요소(모듈)을 스케줄러라고 한다.
[멀티 프로세스 환경에서 스케줄링을 하는 이유]
우선 프로세스를 실행하는 형태를 크게 둘로 나눠서 보면 다음과 같다.
위에서 예로 들었던 프로세스 A, B, C가 있다고 치자.
1) 고전적인 방식
A, B, C를 순서대로 실행시키되, A가 끝나면 B, 그리고 B가 끝나면 C를 실행시키는 방식이다.
다시 말해서 순차적으로 실행시키는 방식이다.
2) 동시에 실행하는 방식
A, B, C를 모두 실행시키면서 운영체제의 스케줄러에 의해 프로세스들이 관리되도록 하는 방식이다.
다시 말해서 정해진 순서에 의해 CPU의 실행 시간을 나눠서 할당받고 실행하는 형태가 된다.
일반적으로 프로그램이 실행되는 과정에서 가장 많은 시간을 쓰는 곳은 입출력(I/O)이다.
입출력은 데이터의 입/출력을 말하는 것으로, 단순하게 파일의 I/O만 뜻하는 것이 아니다.
네트워크(인터넷)와 연결되어 있는 호스트(컴퓨터)와 데이터를 송수신 하는 것도 I/O가 될 수 있다.
실제로 특정 웹 페이지를 호출하기 위해서 URL을 입력하면 웹 페이지를 전송받는 것이 그 예가 될 수 있다.
그리고 네트워크에 많은 부하가 걸려있는 상태라면 I/O 과정에서 시간이 걸리게 된다.
만약 웹 페이지를 로드하는데 다른 프로세스가 실행되지 않고 계속 기다리게 된다면 효율은 떨어진다고 볼 수 있다.
여기서 I/O과정을 고려하면 고전적인 방식은 효율적이지 못하다.
그래서 스케줄링을 통해 A가 I/O에 관련된 일을 하고 있으면 B나 C 프로세스를 실행하도록 하는 것이 효율적이다.
[프로세스의 상태 변화]
멀티 프로세스 운영체제에서는 고전적인 방식이 아닌 여러 프로세스가 돌아가면서 실행된다.
그래서 프로세스의 각각의 상태는 위의 그림처럼 계속 바뀌게 되고, 이는 시간 흐름에 따르게 된다.
상태가 변하는 상황은 크게 다섯가지로, 나눠서 볼 수 있다.
1) S(Start)에서 Ready 상태로의 전이
S는 프로세스가 생성되었음을 의미한다.
그리고 프로세스는 생성됨과 동시에 Ready 상태가 되고 Ready 큐(우리가 아는 그 Queue)에 들어가게 된다.
프로세스는 생성되자마자 바로 실행(Running)된다고 생각할 수 있다.
하지만 이 프로세스만 있는 것이 아니라 다른 프로세스도 실행 중에 있을 것이다.
그 프로세스를 무조건 멈추고 자기가 먼저 실행될 수는 없다.
그래서 Ready 큐에서 스케줄러에 의해 실행되기 전까지 자기 순서를 기다리는 것이다.
2) Ready 상태에서 Running 상태로의 전이
Ready 상태에 있는 프로세스는 스케줄러에 의해서 관리된다.
스케줄러는 프로세스는 Ready 큐에 들어있는 프로세스를 순서대로 정해진 스케줄링 알고리즘에 따라 실행한다.
결론적으로는 스케줄링 알고리즘에 따라 작업을 수행하는 스케줄러에 의해 프로세스는 Ready에서 Running 상태가 된다.
3) Running 상태에서 Ready 상태로의 전이
우리가 살다보면 여러 일 중에서도 먼저해야 일이 있다.
그래서 일의 우선순위를 정해서 어떤 것부터 먼저하고 어떤 것은 나중에 하게 된다.
프로세스도 마찬가지로 우선순위라는 개념이 존재한다.
프로세스들은 생성 시에 중요도에 따라서 우선순위(Priority)가 매겨진다.
예를 들면 다음과 같다.
우선순위가 낮은 프로세스 B가 Running 중인데, 우선순위가 높은 프로세스 A가 생성되고 Ready 큐에 들어오게 된다.
그러면 스케줄러는 우선순위가 높은 프로세스 A를 확인하고 현재 실행중인 프로세스 B를 Ready큐로 돌려보낸다.
그리고 프로세스 A를 실행하게 되며, 프로세스 B는 프로세스 A의 작업이 종료되는 것을 기다리게 된다.
4) Running 상태에서 Blocked 상태로의 전이
이 경우는 Ready로 돌아가는 것과는 많이 다르다.
Blocked 상태로 들어갔다는 것은 프로세스가 실행을 멈추는 상태로 들어갔다는 것을 말한다.
일반적으로는 I/O와 관련된 일을 하는 경우에 발생하게 된다.
이전에 설명했던 것처럼 프로그램의 실행에 있어서 상당 시간은 I/O에 소모하게 된다.
이 시간 동안은 CPU에 의해서 프로세스가 더 실행될 수가 없다.
그래서 I/O를 진행 중인 프로세스는 Blocked 상태로 보내고, Ready 큐에 있는 프로세스 중 하나를 대신 실행시킨다.
5) Blocked 상태에서 Ready 상태로 전이
Ready와 달리 Blocked 상태는 스케줄러에 의한 실행 고려 대상이 아니다.
Ready와 Blocked 모두 프로세스가 실행이 되는 상태는 아니다.
하지만 Ready 상태는 스케줄러에 의해 언제든지 다시 Running 상태가 될 수 있다.
Blocked 상태는 Ready 상태와 달리 스케줄러에 의해 다시 Running 상태가 될 수 없다.
그래서 Blocked 상태는 크게 다음과 같이 볼 수 있다.
1 - 프로세스의 종료에 의해 Blocked 상태로 들어간 후 E(Exit) 상태로
2 - I/O와 같은 다른 작업에 의해 CPU의 프로세스 실행이 불가능한 경우 Blocked 상태로
여기서 2의 경우는 해당 작업이 끝난 이후 다시 Ready 상태로 돌아간다.
그리고 스케줄러에 의해 Running 상태가 될 때까지 큐에서 자기 순서를 기다리게 된다.
[컨텍스트 스위칭(Context Switching)]
지금까지 CPU 실행시간을 여러 프로세스들이 나눠서 실행하여 동시에 실행하는 효과를 받고 있다는 사실을 알았다.
그리고 프로그램 실행의 상당 시간을 I/O에 소모하기 때문에 둘 이상의 프로세스 실행은 CPU의 활용도를 높인다.
이는 결과적으로 성능 향상이 된다는 결과도 알게 되었다.
근데 세상에 좋은 것만이 어디 있겠는가.
장점이 있으면 단점도 있기 마련이다.
단점은 "실행 중인 프로세스(Running 상태의 프로세스)의 변경은 시스템에 많은 부하를 가져다 주기도 한다"는 것이다.
이게 무슨 말일까?
앞에서 말했던 프로세스의 구성 요소 중 레지스터와 관련된 이야기를 했었다.
"CPU 내에 존재하는 레지스터들은 현재 실행 중인 프로세스 관련 데이터들로 채워진다."
이 말은 실행 중인 프로세스가 변경되면 CPU 내의 레지스터들의 값도 변경이 되어야 한다는 말이다.
현재 프로세스 A가 실행 중인 상황이고, 프로세스 B는 Ready 큐에서 Running 상태가 되기를 기다리는 중이다.
스케줄링 알고리즘에 의해 일정 시간 실행 후에는 프로세스 B가 실행이 될 것이다.
그런데 프로세스 A의 실행이 완전히 끝난 상황이 아니다.
Ready 상태로 갔다가 다시 실행되어야 하는 프로세스다.
그래서 프로세스 B가 실행되기 전에 현재 레지스터들이 지니고 있는 데이터는 어딘가에 저장이 되어야 한다.
그럼, 반대의 경우라고 다를 것이 있을까?
더 나아가서 프로세스 B 역시 프로세스 A와 마찬가지로 중간에 실행되다가 Ready 큐에 들어갔던 프로세스라면?
프로세스 B가 실행될 때 레지스터들이 가지고 있던 프로세스 B의 데이터를 어딘가에 저장을 했을 것이다.
그 데이터를 다시 레지스터에 복원해야 프로세스 B가 수행하던 작업을 이어서 진행하게 될 것이다.
위의 그림에서는 프로세스 A가 실행 중인 상태이고, 프로세스 B는 메모리에 레지스터 정보를 백업해놓은 상태이다.
여기서 프로세스 B가 실행되는 상황이라면
위 그림과 같이 프로세스 B의 데이터를 레지스터에 다시 채워넣어야 한다.
이와 같은 과정을 '컨텍스트 스위칭(Context Switching, 문맥 교환)' 이라고 한다.
프로세스 A의 레지스터 정보는 메모리에 저장된다.
그리고 프로세스 B와 관련된 레지스터 정보는 메모리에서 CPU의 레지스터로 복원하게 된다.
이제 이와 관련된 결론을 내리자면 다음과 같다.
"실행되는 프로세스의 변경과정에서 발생하는 컨텍스트 스위칭은 시스템에 많은 부담을 준다."
레지스터의 개수가 많은 시스템일수록, 프로세스 별 관리되어야 하는 데이터의 종류가 많을 수록 부담이 커지게 된다.
이것이 멀티 프로세스 운영체제가 가지는 단점이다.
그래서 시스템을 설계하는 데 있어서, 이러한 컨텍스트 스위칭 부담을 최소화하기 위한 노력을 기울이게 된다.
이제 시스템 프로그래밍을 하면서 장점과 단점 두 가지를 모두 고려해야 한다.
첫 번째로 프로그램의 실행과정에서 발생하는 I/O 차원에서의 멀티 프로세스 기반의 프로그램 실행은?
분명히 많은 부분에 있어서 성능 향상에 도움을 줄 수 있다.
하지만 두 번째로 컨텍스트 스위칭이 미치는 영향을 생각해본다면?
이는 성능의 저하를 가져올 수 있는 요소가 된다.
그래서 이 두 가지 사항을 염두에 둬야한다.
과연 어느 쪽을 우선시해야 할까를 물어보면 거기에 대해서는 나도 모른다.
책의 저자도 모른다고 했다.
말 그대로 통밥(?)이 필요한 영역이고, 직접 균형을 잡아가며 줄을 타는 방법을 터득하는 수 밖에 없다.
[프로세스의 생성]
이제부터 진짜 프로세스를 생성하고 소멸시키는 과정을 진행해볼까 한다.
앞에서는 프로그램 코드가 단 하나도 안나와서 지루했을까 싶을지도 모르겠다.
하지만 다시 한 번 보면서 새로운 해석을 할 수도 있어서 괜찮았던 것 같다.
학생 때는 그냥 머리에 구겨넣고 학점만 잘받으려고 했었지, 이렇게까지 생각한적은 없었기 때문이다.
특히나 지금처럼 이렇게 글로 남겨가면서 공부한 적도 없었고.
여튼 헛소리는 집어치우고, 이제부터 작성했던 코드들을 올려가면서 설명해보려고 한다.
[프로세스의 생성]
우리가 알고 있는 프로세스 생성 방법 중 가장 쉬운 방법이 있다.
실행파일을 더블 클릭해서 실행하면 끝이다.
그런데 시스템 프로그래밍을 통해서 다른 방법으로 프로세스를 생성하는 것이 가능하다.
"프로그램 실행 중에 또 하나의 프로세스를 생성"
아마 리눅스를 공부해보셨던 분들은 fork() 함수가 번뜩 떠올랐을지도 모르겠다.
Windows라고 다르겠는가?
마찬가지로 프로세스 내에서 프로세스를 생성하는 것은 여기서도 가능하다.
[CreateProcess 함수의 이해]
Windows에서는 리눅스의 fork()와 마찬가지로 CreateProcess라는 함수를 이용하여 프로세스 생성을 지원한다.
그래서 CreateProcess 함수를 호출하는 대상은 부모(Parent) 프로세스가 된다.
그리고 함수 호출로 인해 생성되는 프로세스는 자식(Child) 프로세스가 된다.
CreateProcess 함수에 대한 정확한 정의는 아래의 링크를 참고하시길 바란다.
책에도 내용이 적혀있지만, 가장 정확한 것은 MS에서 정의하는 것을 확인하는 것이 제일 정확하다.
[예제를 통한 CreateProcess 함수의 이해]
[AdderProcess.cpp]
/*
* Windows System Programming - 프로세스 생성 예제
* 파일명: AdderProcess.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-03
* 이전 버전 작성 일자:
* 버전 내용: 프로세스를 생성하는 간단한 예제 구현
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
int _tmain(int argc, TCHAR* argv[])
{
DWORD val1, val2;
// _ttoi는 MBCS 기반이면 atoi, 유니코드 기반이면 _wtoi를 호출
// 동시지원을 위해 지원하는 함수.
val1 = _ttoi(argv[1]);
val2 = _ttoi(argv[2]);
_tprintf(_T("%d + %d = %d\n"), val1, val2, val1 + val2);
// getchar와 마찬가지로 MBCS 기반이면 getchar, 유니코드 기반이면 getwchar 함수를 호출
// 동시 지원을 위해 지원하는 함수.
// 프로그램의 실행을 잠시 멈추기 위해서 사용
_gettchar();
return 0;
}
[CreateProcess.cpp]
/*
* Windows System Programming - 프로세스 생성 예제
* 파일명: CreateProcess.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-03
* 이전 버전 작성 일자:
* 버전 내용: 프로세스를 생성하는 간단한 예제 구현
* 이전 버전 내용:
*/
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#define DIR_LEN MAX_PATH+1 // 디렉터리 경로의 최대 길이를 매크로로 선언
int _tmain(int argc, TCHAR* argv[])
{
STARTUPINFO si = { 0, }; // STARTUPINFO 구조체를 0으로 초기화
PROCESS_INFORMATION pi; // PROCESS_INFORMATION 구조체
TCHAR npTitle[] = _T("New Process!"); // 새로운 프로세스의 콘솔 윈도우의 타이틀에 사용할 문자열
// STARTUPINFO 구조체의 멤버 변수 초기화
si.cb = sizeof(si); // 구조체 변수의 크기, 형식적으로 사용하는 것이 크다.
si.dwFlags = STARTF_USEPOSITION | STARTF_USESIZE; // 반영하고자 하는 멤버에 대한 정보 설정
si.dwX = 100; // 콘솔의 위치 (X축)
si.dwY = 200; // 콘솔의 위치 (Y축)
si.dwXSize = 300; // 콘솔의 크기(X축)
si.dwYSize = 200; // 콘솔의 크기(Y축)
/*
* C++ 11 이후로 char* 로 const char* 를 가리키는 것을 불가능함.
* 사실 C에서도 문자열은 char*로 가리키는 것이 아니라 const char*로 가리키는 것이 맞음.
* 따라서 문자열 리터럴은 const char*이므로 책에 있는 예제 코드는 그대로 사용 불가능
* si.lpTitle = _T("New Process!");
* _T("New Process!")는 const wchar_t* 인데, lpTitle의 형은 LPWSTR(wchar_t*)이다.
* 그래서 위에 새로운 프로세스의 콘솔 윈도우의 타이틀에 사용할 문자열을 배열에 넣었다.
*/
si.lpTitle = npTitle; // 새로운 프로세스의 콘솔 창에서 쓸 타이틀 바 제목
TCHAR command[] = _T("AdderProcess.exe 10 20"); // CreateProcess의 두 번째 인자로 전달할 문자열
TCHAR cDir[DIR_LEN]; // 현재 디렉터리 경로를 저장할 배열
BOOL state; // 프로세스 생성 성공 여부를 확인하기 위해 사용한 변수
GetCurrentDirectory(DIR_LEN, cDir); // 현재 디렉터리 확인
_fputts(cDir, stdout);
_fputts(_T("\n"), stdout);
SetCurrentDirectory(_T("D:\\WinSystem")); // 현재 디렉터리를 지정하는 함수.
GetCurrentDirectory(DIR_LEN, cDir); // 현재 디렉터리 확인
_fputts(cDir, stdout);
_fputts(_T("\n"), stdout);
state = CreateProcess(
// 첫 번째 인자를 NULL로 전달하면 두 번째 인자를 통해서 생성하려는 프로세스의 이름 정보까지 함께 전달 가능
// 첫 번째 인자로 실행파일의 이름을 전달하는 경우에는 '현재 디렉터리'를 기준으로 실행파일을 찾음
NULL,
// 두 번째 인자로 실행파일의 이름을 전달하는 경우에는 '표준 검색경로' 순서대로 실행파일을 찾아서 프로세스를 생성
// 추가로 유니코드 버전에서는 _T("AdderProcess.exe 10 20");를 바로 넣으면 런타임 오류가 난다.
// CreateProcess 함수는 내부적으로 문자열에 변경을 가하기 때문.
// 함수 호출이 끝날 때는 변경된 문자열을 다시 원래 상태로 돌려놓기 때문에 변경이 되는 것을 인지하기 어려움
// 그래서 전달인자의 문자열은 반드시 '변수'형태로 전달할 것.
command,
NULL, NULL, TRUE,
// 특성을 결정하는 인자로, 새로운 콘솔창을 띄울 때 쓰는 전달 인자.
// 만약 0을 전달한다면 부모 프로세스의 콘솔 윈도우를 자식 프로세스가 공유한다.
CREATE_NEW_CONSOLE,
// STARTUPINFO 구조체와 PROCESS_INFORMATION 구조체를 인자로 전달.
// PROCESS_INFORMAION구조체는 새로 생성되는 프로세스 관련 정보를 얻기 위해 사용.
NULL, NULL, &si, &pi
);
if (state != 0) // 프로세스 생성이 성공했다면
_fputts(_T("Creation Success! \n"), stdout);
else // 실패했다면
_fputts(_T("Creation Error! \n"), stdout);
return 0;
}
코드 자체는 복잡한 것이 없다.
CreateProcess 함수와 그 이외의 함수들을 실제로 사용하는 예시가 들어있는 정도다.
여기서 STARTUPINFO와 PROCESS_INFORMATION이라는 구조체가 처음으로 나오게 된다.
https://learn.microsoft.com/en-us/windows/win32/api/_processthreadsapi/
마찬가지로 MS에서 구조체에 대한 정보를 다 제공합니다.
찾아서 확인해보시면 됩니다.
제가 굳이 설명을 안하는 이유는 저도 잘 몰라서 그렇습니다.
그리고 점점 하시다보면 알겠지만 결국 여기서 찾아볼 일이 엄청나게 많습니다.
책의 저자도 이거 없으면 개발 못한다고 할만큼 여기는 이제 제 집 드나들듯 들락날락 하셔야 될겁니다.
https://learn.microsoft.com/en-us/windows/apps/
어지간하면 위의 링크는 북마크해두시고 항상 찾아본다는 생각으로 하시면 됩니다.
[실습을 위한 환경의 구성 및 실행]
사실 위의 코드를 잘 이해하셨다면 어떻게 실행할지는 대충 감이 오실겁니다.
그렇다고 이 글을 보면서 따라하시는 분이 얼마나 있을지는 모르겠습니다.
일단 실행 방법은 크게 두 가지가 있습니다.
첫 번째로는 Visual Studio를 기준으로 한다고 했을 때, 여러분들이 만든 프로젝트 폴더가 있을겁니다.
거기서 Debug로 들어가면 실행파일이 있는 디렉터리가 있습니다.
거기에 AdderProcess 실행파일을 같이 넣어주면 됩니다.
그리고 두 번째로는 '표준 검색 경로'를 활용하는 방법입니다.
CreateProcess 함수의 인자로 두 번째 인자에 실행파일 이름을 넘겨줬습니다.
그러면 이 프로그램은 '표준 검색 경로'라는 것을 이용해서 실행파일을 찾게 됩니다.
여기서 말하는 '표준 검색 경로'라는 것은 다음과 같습니다.
1 - 실행 중인 프로세스의 실행파일이 존재하는 디렉터리
2 - 실행 중인 프로세스의 현재 디렉터리 (Current Directory)
3 - Windows의 시스템 디렉터리 (System Directory)
4 - Windows 디렉터리 (Windows Directory)
5 - 환경변수 PATH에 의해 지정되어 있는 디렉터리
위의 예제에서는 현재 디렉터리를 D:\WinSystem으로 바꿔서 실행을 하게 됩니다.
그러니 D드라이브에 WinSystem이라는 폴더를 만드시고 거기에 AdderProcess 실행파일을 넣어두시면 됩니다.
[프로세스 생성과 관련된 예제 그리고 문제점]
[Calculator.cpp]
/*
* Windows System Programming - 프로세스 생성 예제
* 파일명: Calculator.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-03
* 이전 버전 작성 일자:
* 버전 내용: 프로세스를 생성하는 간단한 예제(2) - 계산기 프로그램
* 이전 버전 내용:
*/
// 이제 scanf가 아닌 _tscanf_s나 _fgetts에 익숙해져야겠다.
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
// 열거형 변수 선언 및 정의
enum { DIV = 1, MUL, ADD, MIN, ELSE, EXIT };
// 메뉴 출력 함수 및 사칙연산 관련 함수 선언
DWORD ShowMenu();
void Divide(double, double);
void Multiple(double, double);
void Add(double, double);
void Min(double, double);
int _tmain(int argc, TCHAR* argv[])
{
STARTUPINFO si = { 0, }; // STARTUPINFO 구조체를 0으로 초기화
PROCESS_INFORMATION pi; // PROCESS_INFORMATION 구조체 생성
si.cb = sizeof(si); // STARTUPINFO 구조체의 크기 초기화
TCHAR command[] = _T("calc.exe"); // 프로세스 생성 시 전달할 인자
// 현재 디렉터리 지정(좋은 예시는 아님, 이럴때는 차라리 완전경로로 쓰는게 나음)
// 그리고 굳이 이렇게 줄 필요도 없는 것이 2번째 인자로 전달하면 표준 검색 경로를 통해서 찾게 됨.
// 그래서 주석처리하고 실행해보면 똑같이 실행되는 것을 확인 가능함
SetCurrentDirectory(_T("C:\\WINDOWS\\system32"));
DWORD sel;
double num1, num2;
while (true)
{
sel = ShowMenu();
if (sel == EXIT)
return 0;
if (sel != ELSE)
{
_fputts(_T("Input Num1, Num2: "), stdout);
_tscanf(_T("%lf %lf"), &num1, &num2);
}
switch (sel)
{
case DIV:
Divide(num1, num2);
break;
case MUL:
Multiple(num1, num2);
break;
case ADD:
Add(num1, num2);
break;
case MIN:
Min(num1, num2);
break;
case ELSE:
ZeroMemory(&pi, sizeof(pi)); // 구조체 변수를 0으로 초기화하는데 사용하는 함수.
CreateProcess(
NULL, command, NULL, NULL,
TRUE, 0, NULL, NULL, &si, &pi);
break;
}
}
return 0;
}
DWORD ShowMenu()
{
DWORD sel;
_fputts(_T("-----Menu-----\n"), stdout);
_fputts(_T("num 1: Divide\n"), stdout);
_fputts(_T("num 2: Multiple\n"), stdout);
_fputts(_T("num 3: Add\n"), stdout);
_fputts(_T("num 4: Minus\n"), stdout);
_fputts(_T("num 5: Any other operations.\n"), stdout);
_fputts(_T("num 6: Exit\n"), stdout);
_fputts(_T("Select Num >> "), stdout);
_tscanf(_T("%d"), &sel);
return sel;
}
void Divide(double a, double b)
{
_tprintf(_T("%lf / %lf = %lf\n\n"), a, b, a / b);
}
void Multiple(double a, double b)
{
_tprintf(_T("%lf * %lf = %lf\n\n"), a, b, a * b);
}
void Add(double a, double b)
{
_tprintf(_T("%lf + %lf = %lf\n\n"), a, b, a + b);
}
void Min(double a, double b)
{
_tprintf(_T("%lf - %lf = %lf\n\n"), a, b, a - b);
}
이제 마지막으로 프로세스 생성을 활용한 예제인 계산기 프로그램 코드입니다.
코드가 무지하게 긴 것 같지만, 실제로는 사칙연산을 하는 지극히 단순한 코드입니다.
사실 이 프로그램에는 아주 큰 문제가 하나 있습니다.
아직 여기서는 다룰 이야기는 아니지만, 확실한건 문제가 있다는 것입니다.
곧 이어서 다루게 될 '커널 오브젝트와 핸들'에서 여기에 무슨 문제가 있는지를 알게 될겁니다.