Chapter 3 Processes: Practice Exercises

목화·2023년 5월 7일
1

3.1

Using the program shown in Figure 3.30, explain what the output will be at LINE A.

#include <sys/types.h>
#include <stdio.h>
#include <unistd.h>

int value = 5;

int main()
{
    pid_t pid;

    pid = fork();

    if (pid == 0)
    { /* child process */
        value += 15;
        return 0;
    }
    else if (pid > 0)
    { /* parent process */
        wait(NULL);
        printf("PARENT: value = %d", value); /* LINE A */
        return 0;
    }
}

이 프로그램은 fork() 시스템 호출을 사용하여 자식 프로세스를 생성하고, 자식과 부모 프로세스 간에 변수 value를 공유하지 않습니다. fork() 이후에 자식 프로세스와 부모 프로세스는 각각 별도의 메모리 공간을 가집니다.

코드를 살펴보면 다음과 같은 실행 과정을 거칩니다:

  1. int value = 5;로 전역 변수 value를 5로 초기화합니다.
  2. fork()를 호출하여 자식 프로세스를 생성합니다.
  3. 자식 프로세스의 경우 pid == 0이므로, value에 15를 더하여 value는 20이 됩니다. 그리고 자식 프로세스는 종료됩니다.
  4. 부모 프로세스의 경우 pid > 0이므로, wait(NULL)을 호출하여 자식 프로세스가 종료될 때까지 기다립니다.
  5. 자식 프로세스가 종료되면, 부모 프로세스는 "PARENT: value = %d"를 출력합니다. 이 때, 부모 프로세스의 value 값은 자식 프로세스에서 변경된 값과는 관계없이 5로 유지됩니다.

따라서, LINE A에서 출력되는 값은 PARENT: value = 5입니다.


3.2

Including the initial parent process, how many processes are created by the program shown in Figure 3.31?

#include <stdio.h>
#include <unistd.h>

int main()
{
    /* fork a child process */
    fork();

    /* fork another child process */
    fork();

    /* and fork another */
    fork();

    return 0;
}

이 프로그램에서 생성되는 프로세스의 개수를 이해하려면 각 fork() 호출이 어떻게 동작하는지 살펴보아야 합니다. fork()는 부모 프로세스를 복제하여 새로운 자식 프로세스를 생성하고, 이후 코드는 부모와 자식 프로세스 모두에서 실행됩니다.

이 코드에서는 fork()를 3번 호출합니다. 각 호출마다 프로세스의 개수가 두 배로 늘어나므로, 프로세스 생성이 다음과 같이 이루어집니다:

  1. 초기 상태: 1개의 프로세스 (부모 프로세스)
  2. 첫 번째 fork() 호출 후: 2개의 프로세스 (부모 + 1개의 자식)
  3. 두 번째 fork() 호출 후: 4개의 프로세스 (부모 + 1개의 자식 + 각각의 자식이 추가로 생성한 자식)
  4. 세 번째 fork() 호출 후: 8개의 프로세스 (부모 + 1개의 자식 + 각각의 자식이 추가로 생성한 자식 + 각각의 자식이 또 추가로 생성한 자식)

따라서, 이 프로그램이 실행되면 초기 부모 프로세스를 포함하여 총 8개의 프로세스가 생성됩니다.


3.3

Original versions of Apple’s mobile iOS operating system provided no means of concurrent processing. Discuss three major complications that concurrent processing adds to an operating system.

동시 처리는 운영 체제의 반응성을 높이고 자원 활용을 개선하는 등 많은 장점을 제공하지만, 운영 체제가 처리해야 하는 몇 가지 문제점도 동반합니다. 다음은 동시 처리가 운영 체제에 추가하는 세 가지 주요 문제점입니다:

  1. 동기화: 여러 프로세스 또는 스레드가 메모리나 데이터 구조와 같은 공유 자원에 액세스할 때, 경쟁 조건이나 불일치를 피하기 위해 동작을 조정해야 할 수 있습니다. 운영 체제는 락, 세마포어 또는 모니터와 같은 동기화 메커니즘을 제공해야 하며, 이를 통해 한 번에 하나의 프로세스 또는 스레드만 공유 자원에 액세스하거나 통제된 방식으로 액세스할 수 있습니다. 이로 인해 운영 체제와 애플리케이션 개발 과정에 복잡성이 추가되며, 개발자는 데드락이나 라이브락과 같은 문제를 피하기 위해 동기화를 신중하게 설계하고 구현해야 합니다.

  2. 프로세스 간 통신 (IPC): 동시 처리는 프로세스나 스레드간에 정보를 교환하거나 활동을 조정해야 하는 경우가 종종 있습니다. 운영 체제는 메시지 전달, 공유 메모리 또는 파이프와 같은 프로세스 간 통신을 용이하게 하는 메커니즘을 제공해야 합니다. 이러한 IPC 메커니즘은 구현 및 유지 관리가 복잡할 수 있으며, 제대로 관리되지 않으면 성능 오버헤드 또는 잠재적인 보안 취약점을 초래할 수 있습니다.

  3. 자원 관리 및 스케줄링: 동시 환경에서 운영 체제는 CPU 시간, 메모리 및 I/O 장치와 같은 시스템 자원을 여러 프로세스 또는 스레드 사이에서 효율적으로 관리해야 합니다. 이는 다음에 실행할 프로세스 또는 스레드를 결정하는 것과 관련하여 우선 순위, 선점 및 공정성을 처리하는 정교한 스케줄링 알고리즘을 설계 및 구현하는 것을 포함합니다.


3.4

Some computer systems provide multiple register sets. Describe what happens when a context switch occurs if the new context is already loaded into one of the register sets. What happens if the new context is in memory rather than in a register set and all the register sets are in use?

새 컨텍스트가 이미 레지스터 세트 중 하나에 로드된 상태에서 컨텍스트 스위치가 발생하면, 프로세서는 레지스터 세트를 전환하여 새 컨텍스트를 사용하게 됩니다. 이 경우 빠르게 컨텍스트 전환을 수행할 수 있으며, 시스템은 별도의 메모리 액세스 없이 새로운 작업을 계속할 수 있습니다. 이러한 빠른 컨텍스트 전환이 가능한 이유는 프로세서가 레지스터 세트를 직접 전환할 수 있기 때문입니다.

그러나 새 컨텍스트가 메모리에 있고 모든 레지스터 세트가 사용 중인 경우에는, 운영 체제가 먼저 현재 실행 중인 프로세스의 컨텍스트를 메모리에 저장해야 합니다. 그런 다음 새 컨텍스트를 메모리에서 로드하여 적절한 레지스터 세트에 저장해야 합니다. 이 과정은 추가적인 메모리 액세스가 필요하므로, 컨텍스트 전환이 더 오래 걸릴 수 있습니다. 이렇게 메모리와 레지스터 사이에서 정보를 이동시키는 작업은 컨텍스트 전환 오버헤드를 증가시키며, 시스템 성능에 영향을 줄 수 있습니다.


3.5

When a process creates a new process using the fork() operation, which of the following states is shared between the parent process and the child process?
a. Stack
b. Heap
c. Shared memory segments

답: c. 공유 메모리 세그먼트

fork() 시스템 호출을 사용하여 새 프로세스를 생성하면, 부모 프로세스와 자식 프로세스는 독립적인 메모리 공간을 갖게 됩니다. 이는 스택(a)과 힙(b)이 서로 다른 메모리 영역에서 관리되어 서로 공유되지 않음을 의미합니다. 그러나 공유 메모리 세그먼트(c)는 두 프로세스 간에 명시적으로 공유되도록 설계되었습니다. 따라서 부모 프로세스와 자식 프로세스 사이에서 공유 메모리 세그먼트를 사용하여 데이터를 주고받을 수 있습니다.


3.6

Consider the “exactly once” semantic with respect to the RPC mechanism. Does the algorithm for implementing this semantic execute correctly even if the ACK message sent back to the client is lost due to a network problem? Describe the sequence of messages, and discuss whether “exactly once” is still preserved.

"정확히 한 번" 의미론은 원격 프로시저 호출이 정확히 한 번만 수행되도록 보장하는 것을 목표로 합니다. ACK 메시지가 네트워크 문제로 손실되는 경우에도 이 의미론이 올바르게 작동하는지 확인해봅시다.

  1. 클라이언트는 서버에 RPC 요청을 전송합니다.
  2. 서버는 요청을 처리하고 결과를 클라이언트에게 반환합니다.
  3. 클라이언트는 서버로부터 결과를 받고 ACK 메시지를 전송합니다.

ACK 메시지가 손실되는 경우, 서버는 요청 처리에 대한 클라이언트의 확인을 받지 못합니다. 이 경우 서버는 요청 처리가 제대로 수행되지 않았다고 판단할 수 있으며, 클라이언트는 다시 요청을 보낼 수 있습니다.

그러나 이러한 방식으로는 "정확히 한 번" 의미론이 보장되지 않습니다. 클라이언트가 중복된 요청을 보내게 되면 서버는 같은 작업을 여러 번 수행하게 됩니다. 이를 해결하기 위해, 서버는 각 요청에 대해 고유한 식별자를 부여하고 처리한 요청을 추적할 수 있어야 합니다. 서버는 중복된 요청을 받으면 이전에 처리한 결과를 반환하고, 실제로 작업을 중복해서 수행하지 않게 됩니다.

이와 같은 방법을 사용하면, ACK 메시지가 손실되더라도 "정확히 한 번" 의미론이 유지됩니다. 서버는 중복 요청을 처리하지 않고 이전에 처리된 결과를 반환하여 클라이언트에게 올바른 결과를 전달할 수 있습니다.

"정확히 한 번" 의미론을 구현하는 예시를 살펴보겠습니다.

고려할 상황: 클라이언트가 원격 서버에 데이터를 추가하는 RPC 요청을 전송하려고 합니다. 이 요청은 정확히 한 번만 수행되어야 합니다.

  1. 클라이언트는 고유한 식별자(예: UUID)와 함께 RPC 요청을 서버에 전송합니다.
{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "method": "add_data",
  "params": { "data": "example_data" }
}
  1. 서버는 요청을 받고, 요청 ID를 확인합니다. 이전에 처리한 요청인지 확인합니다. 만약 이전에 처리한 요청이 아니면 서버는 요청을 처리하고 결과를 클라이언트에게 반환합니다.
{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "result": "success"
}
  1. 클라이언트는 서버로부터 결과를 받고, ACK 메시지를 전송합니다.
{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "ack": true
}
  1. 만약 ACK 메시지가 손실되면 클라이언트는 다시 같은 요청을 보낼 수 있습니다. 이때 서버는 요청 ID를 확인하고, 이미 처리한 요청임을 인식합니다. 이 경우 서버는 중복 작업을 수행하지 않고 이전에 처리된 결과를 반환합니다.
{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "result": "success"
}

이러한 방식으로 "정확히 한 번" 의미론이 구현되며, 서버는 중복된 요청을 처리하지 않고 이전에 처리된 결과를 반환하여 클라이언트에게 올바른 결과를 전달할 수 있습니다. 이 과정에서 ACK 메시지가 손실되어도 정확한 처리가 가능하며, 클라이언트와 서버 간의 통신이 안정적으로 이루어집니다.


3.7

Assume that a distributed system is susceptible to server failure. What mechanisms would be required to guarantee the “exactly once” semantic for execution of RPCs?

서버 실패에 취약한 분산 시스템에서 "정확히 한 번" 의미론을 보장하기 위한 메커니즘이 필요합니다. 다음과 같은 메커니즘들이 필요합니다.

  1. 요청 식별자: 각 RPC 요청에 고유한 식별자(UUID와 같은)를 부여하여 중복 요청을 피할 수 있도록 합니다. 이를 통해 서버는 이전에 처리한 요청과 새로운 요청을 구분할 수 있습니다.

  2. 항목지약(itempotency): 서버는 동일한 요청이 여러 번 전송되더라도 동일한 결과를 생성하는 방식으로 설계되어야 합니다. 이를 통해 서버가 중복 요청을 처리하더라도 원하는 "정확히 한 번" 의미론이 유지됩니다.

  3. 재시도 메커니즘: 클라이언트는 서버로부터 응답을 받지 못하거나, ACK 메시지가 손실된 경우 요청을 재시도할 수 있어야 합니다. 이를 통해 서버가 실패한 경우에도 클라이언트가 올바른 결과를 얻을 수 있습니다.

  4. 서버 상태 저장 및 복구: 서버는 처리된 요청에 대한 상태를 저장하여 서버 실패 후에도 처리된 요청을 복원할 수 있도록 합니다. 이를 통해 서버가 다시 시작되더라도 이전에 처리된 요청의 상태를 유지할 수 있습니다.

  5. 타임아웃 및 오류 처리: 클라이언트와 서버는 요청과 응답에 대한 타임아웃을 설정하여, 통신 오류 발생 시 적절하게 대응할 수 있어야 합니다. 또한, 오류 처리 메커니즘을 통해 클라이언트와 서버가 예외 상황에 대처할 수 있도록 합니다.

이러한 메커니즘들을 통해 서버 실패가 발생하더라도 "정확히 한 번" 의미론을 보장할 수 있으며, 분산 시스템에서 안정적인 RPC 실행이 가능해집니다.


3.8

Describe the actions taken by a kernel to context-switch between processes.

커널이 프로세스 간 컨텍스트 스위치를 수행하기 위해 취하는 조치는 다음과 같습니다.

  1. 현재 실행 중인 프로세스 정보 저장: 커널은 현재 실행 중인 프로세스의 상태(레지스터 값, 프로그램 카운터 등)를 해당 프로세스의 프로세스 제어 블록(PCB, Process Control Block)에 저장합니다. 이를 통해 나중에 프로세스가 다시 실행될 때 이전 상태로 복원할 수 있습니다.

  2. 다음 실행할 프로세스 결정: 커널은 스케줄러를 사용하여 다음에 실행할 프로세스를 결정합니다. 스케줄러는 우선순위, 라운드 로빈, 최단 작업 우선 등 다양한 알고리즘을 사용할 수 있습니다.

  3. 새 프로세스 정보 로드: 커널은 선택된 프로세스의 PCB에서 필요한 정보(레지스터 값, 프로그램 카운터 등)를 로드하고, 이를 시스템 레지스터에 설정합니다. 이렇게 하면 새 프로세스의 실행 상태가 복원됩니다.

  4. 새 프로세스 실행: 커널은 새 프로세스의 실행을 시작합니다. 프로그램 카운터 값에 따라 새 프로세스의 코드가 실행되며, 프로세스가 중단되었던 지점부터 계속 실행됩니다.

이러한 과정을 통해 커널은 프로세스 간 컨텍스트 스위치를 수행하며, 멀티태스킹 환경에서 여러 프로세스가 동시에 실행되는 것처럼 보이게 합니다.


3.9

Construct a process tree similar to Figure 3.7. To obtain process infor- mation for the UNIX or Linux system, use the command ps -ael. Use the command man ps to get more information about the ps command. The task manager on Windows systems does not provide the parent process ID, but the process monitor tool, available from technet.microsoft.com, provides a process-tree tool.

UNIX 또는 Linux 시스템에서 프로세스 정보를 얻기 위해 ps -ael 명령어를 사용하여 Figure 3.7과 유사한 프로세스 트리를 구성할 수 있습니다. ps 명령어에 대한 자세한 정보는 man ps 명령어를 사용하여 확인할 수 있습니다.

윈도우 시스템의 작업 관리자는 부모 프로세스 ID를 제공하지 않지만, technet.microsoft.com에서 제공하는 프로세스 모니터 도구를 사용하면 프로세스 트리 도구를 이용할 수 있습니다.

이렇게 구성된 프로세스 트리를 통해 시스템에서 실행 중인 프로세스와 그들 간의 상호 관계를 파악할 수 있습니다. 이를 통해 시스템의 성능을 모니터링하거나 문제를 진단하는데 도움이 됩니다.


3.10

Explain the role of the init (or systemd) process on UNIX and Linux systems in regard to process termination.

UNIX 및 Linux 시스템에서 init (또는 systemd) 프로세스는 프로세스 종료와 관련하여 중요한 역할을 수행합니다. init 프로세스는 시스템 부팅 시 생성되며, 프로세스 ID 1을 가진 최상위 프로세스입니다. systemd는 최근의 Linux 배포판에서 init 대신 사용되는 시스템 및 서비스 관리자입니다.

init (또는 systemd)의 주요 역할 중 하나는 자식 프로세스가 종료될 때 이를 처리하는 것입니다. 프로세스가 종료되면, 해당 프로세스는 종료 상태를 저장하고 "좀비 프로세스"(zombie process) 상태가 됩니다. 부모 프로세스는 자식 프로세스의 종료 상태를 확인하고 처리한 후, 좀비 프로세스를 완전히 제거합니다.

그러나 부모 프로세스가 종료되거나 종료 상태를 확인하지 못할 경우, 좀비 프로세스가 시스템에서 계속 남아있게 됩니다. 이런 상황에서 init (또는 systemd) 프로세스가 중요한 역할을 수행합니다. 부모 프로세스가 종료되면, 해당 프로세스의 모든 자식 프로세스는 init (또는 systemd) 프로세스에게 '입양'되어 그들의 부모 프로세스가 됩니다. init (또는 systemd)는 이후 종료된 자식 프로세스의 종료 상태를 처리하고, 좀비 프로세스를 제거함으로써 시스템 리소스를 회수합니다. 이를 통해 시스템의 안정성과 성능이 유지됩니다.


3.11

Including the initial parent process, how many processes are created by the program shown in Figure 3.32?

#include <stdio.h>
#include <unistd.h>

int main()
{
    int i;

    for (i = 0; i < 4; i++)
        fork();

    return 0;
}

코드에서 초기 부모 프로세스를 포함하여 총 31개의 프로세스가 생성됩니다. 각 반복마다 fork()가 실행되어 프로세스의 수가 두 배로 증가하기 때문입니다. 4번의 반복이 있으므로 2^4 - 1 = 15개의 자식 프로세스가 생성되고, 초기 부모 프로세스를 포함하면 총 16개의 프로세스가 생성됩니다.


3.12

Explain the circumstances under which the line of code marked printf("LINE J") in Figure 3.33 will be reached.

#include <sys/types.h>
#include <stdio.h>
#include <unistd.h>

int main()
{
    pid_t pid;

    /* fork a child process */
    pid = fork();

    if (pid < 0)
    { /* error occurred */
        fprintf(stderr, "Fork Failed");
        return 1;
    }
    else if (pid == 0)
    { /* child process */
        execlp("/bin/ls", "ls", NULL);
        printf("LINE J");
    }
    else
    {   /* parent process */
        /* parent will wait for the child to complete */
        wait(NULL);
        printf("Child Complete");
    }

    return 0;
}

Figure 3.33의 코드에서 printf("LINE J")가 실행되는 경우는 매우 드뭅니다. 자식 프로세스의 경우, execlp() 함수가 "/bin/ls" 명령을 실행하여 현재 디렉토리의 파일 목록을 출력합니다. execlp()는 호출된 프로세스의 이미지를 새로운 프로그램으로 대체하는데, 이 과정이 정상적으로 진행되면 execlp()는 반환되지 않습니다.

따라서, printf("LINE J")가 실행되려면 execlp() 함수 호출이 실패해야 합니다. 이러한 상황은 "/bin/ls" 파일이 없거나 실행 권한이 없는 경우 등으로 발생할 수 있습니다. 이 경우에만 execlp()가 실패하고, 그 이후의 코드인 printf("LINE J")가 실행됩니다.


3.13

Using the program in Figure 3.34, identify the values of pid at lines A, B, C, and D. (Assume that the actual pids of the parent and child are 2600 and 2603, respectively.)

#include <sys/types.h>
#include <stdio.h>
#include <unistd.h>

int main()
{
    pid_t pid, pid1;

    /* fork a child process */
    pid = fork();

    if (pid < 0)
    { /* error occurred */
        fprintf(stderr, "Fork Failed");
        return 1;
    }
    else if (pid == 0)
    { /* child process */
        pid1 = getpid();
        printf("child: pid = %d", pid);   /* A */
        printf("child: pid1 = %d", pid1); /* B */
    }
    else
    { /* parent process */
        pid1 = getpid();
        printf("parent: pid = %d", pid);   /* C */
        printf("parent: pid1 = %d", pid1); /* D */
        wait(NULL);
    }

    return 0;
}

Figure 3.34의 프로그램에서 각각의 라인 A, B, C, D에서 pid의 값은 다음과 같습니다. (부모 프로세스와 자식 프로세스의 실제 pid가 각각 2600과 2603이라고 가정합니다.)

A: 자식 프로세스에서 pid 값은 0입니다. fork() 함수가 자식 프로세스에서 0을 반환하기 때문입니다.
B: 자식 프로세스에서 pid1 값은 2603입니다. getpid() 함수가 자식 프로세스의 pid를 반환하기 때문입니다.
C: 부모 프로세스에서 pid 값은 2603입니다. fork() 함수가 부모 프로세스에서 자식 프로세스의 pid를 반환하기 때문입니다.
D: 부모 프로세스에서 pid1 값은 2600입니다. getpid() 함수가 부모 프로세스의 pid를 반환하기 때문입니다.


3.14

Give an example of a situation in which ordinary pipes are more suitable than named pipes and an example of a situation in which named pipes are more suitable than ordinary pipes.

일반 파이프가 명명된 파이프보다 더 적합한 상황의 예시:
두 프로세스가 부모-자식 관계에 있고, 서로 통신하기 위해 일회성 파이프라인이 필요한 경우 일반 파이프를 사용하는 것이 더 적합합니다. 이 경우, 프로세스간에 데이터를 전송할 때 임시적으로 파이프를 사용하며, 프로세스가 종료되면 파이프도 사라집니다.

명명된 파이프(named pipes)가 일반 파이프보다 더 적합한 상황의 예시:
서로 관련이 없거나 독립적으로 실행되는 프로세스들이 서로 통신해야 하는 경우 명명된 파이프를 사용하는 것이 더 적합합니다. 명명된 파이프는 파일 시스템에 존재하는 특별한 파일로서, 다양한 프로세스들이 이를 통해 소통할 수 있습니다. 이러한 파이프는 프로세스가 종료된 이후에도 계속 존재하여, 다른 프로세스들이 나중에 이를 사용할 수 있습니다.


3.15

Consider the RPC mechanism. Describe the undesirable consequences that could arise from not enforcing either the “at most once” or “exactly once” semantic. Describe possible uses for a mechanism that has neither of these guarantees.

"최대 한 번" 또는 "정확히 한 번" 시맨틱을 강제하지 않을 경우의 불필요한 결과:
1. 중복 실행: 요청된 작업이 여러 번 실행될 수 있습니다. 이로 인해 데이터의 무결성이 손상되거나, 예기치 않은 결과가 발생할 수 있습니다.
2. 작업의 불완전한 실행: 요청된 작업이 완전히 실행되지 않거나, 일부만 실행될 수 있습니다. 이로 인해 시스템이 정상적으로 작동하지 않거나, 사용자가 원하는 결과를 얻지 못할 수 있습니다.

이러한 보장이 없는 메커니즘의 가능한 사용 사례:
이러한 보장이 없는 메커니즘은 데이터의 정확성이나 완전성이 덜 중요한 상황에서 사용할 수 있습니다. 예를 들어, 실시간 스트리밍, 로그 기록 또는 센서 데이터 수집과 같은 경우에는 일부 데이터가 손실되거나 중복되더라도 전체 작동에 큰 문제가 없습니다. 이러한 상황에서는 성능과 효율성이 더 중요한 요소로 작용할 수 있으며, "최대 한 번" 또는 "정확히 한 번" 시맨틱의 강제가 불필요한 오버헤드를 초래할 수 있습니다.


3.16

Using the program shown in Figure 3.35, explain what the output will be at lines X and Y.

#include <sys/types.h>
#include <stdio.h>
#include <unistd.h>

#define SIZE 5

int nums[SIZE] = {0, 1, 2, 3, 4};

int main()
{
    int i;
    pid t pid;

    pid = fork();

    if (pid == 0)
    {
        for (i = 0; i < SIZE; i++)
        {
            nums[i] *= -i;
            printf("CHILD: %d ", nums[i]); /* LINE X */
        }
    }
    else if (pid > 0)
    {
        wait(NULL);
        for (i = 0; i < SIZE; i++)
            printf("PARENT: %d ", nums[i]); /* LINE Y */
    }

    return 0;
}

Figure 3.35에 나타난 프로그램을 사용하여 X와 Y 라인에서의 출력을 설명하겠습니다.

이 프로그램은 먼저 자식 프로세스를 생성합니다. 자식 프로세스는 배열의 각 원소에 대해 원소의 인덱스와 음수를 곱한 결과로 변경합니다. 그리고 변경된 결과를 출력합니다. 이후 부모 프로세스는 변경되지 않은 원래의 배열을 출력합니다.

LINE X의 출력 (자식 프로세스의 출력)은 다음과 같습니다:

CHILD: 0 CHILD: -1 CHILD: -4 CHILD: -9 CHILD: -16

LINE Y의 출력 (부모 프로세스의 출력)은 다음과 같습니다:

PARENT: 0 PARENT: 1 PARENT: 2 PARENT: 3 PARENT: 4

자식 프로세스는 배열의 값을 변경하지만, 부모 프로세스의 배열은 그대로 유지되므로 부모 프로세스의 출력은 원래 배열과 동일합니다. 이것은 프로세스 간에 메모리 공간이 공유되지 않기 때문입니다.


3.17

What are the benefits and the disadvantages of each of the following? Consider both the system level and the programmer level.
a. Synchronous and asynchronous communication
b. Automatic and explicit buffering
c. Send by copy and send by reference
d. Fixed-sized and variable-sized messages

각각의 이점과 단점을 시스템 레벨과 프로그래머 레벨에서 고려해보겠습니다.

a. 동기식 및 비동기식 통신

  • 동기식 통신의 이점: 통신의 순서와 타이밍이 명확하여 프로그래밍이 간단하고 예측 가능하다.
  • 동기식 통신의 단점: 프로세스가 통신이 완료될 때까지 대기해야 하므로 자원 활용이 비효율적이다.
  • 비동기식 통신의 이점: 프로세스가 통신 도중에 다른 작업을 수행할 수 있어 자원 활용이 효율적이다.
  • 비동기식 통신의 단점: 순서와 타이밍을 관리해야 하는 부담이 늘어나며 프로그래밍이 복잡해진다.

b. 자동 및 명시적 버퍼링

  • 자동 버퍼링의 이점: 프로그래머가 버퍼 관리에 신경 쓸 필요가 없어 코드 작성이 간단하다.
  • 자동 버퍼링의 단점: 시스템이 버퍼 관리를 담당하므로 프로그래머의 제어가 불가능하다.
  • 명시적 버퍼링의 이점: 프로그래머가 버퍼 관리를 제어할 수 있어 성능과 메모리 활용을 최적화할 수 있다.
  • 명시적 버퍼링의 단점: 프로그래머가 버퍼 관리를 직접 처리해야 하므로 코드 작성이 복잡해진다.

c. 복사 전송 및 참조 전송

  • 복사 전송의 이점: 메시지를 독립적으로 처리할 수 있어 프로그래밍이 단순하다.
  • 복사 전송의 단점: 데이터를 복사해야 하므로 성능 저하와 메모리 낭비가 발생할 수 있다.
  • 참조 전송의 이점: 데이터를 직접 참조하므로 성능이 높고 메모리 효율이 좋다.
  • 참조 전송의 단점: 데이터에 대한 동시 접근을 관리해야 하므로 프로그래밍이 복잡해진다.

d. 고정 크기 및 가변 크기 메시지

  • 고정 크기 메시지의 이점: 메시지 처리가 단순하고 예측 가능하며, 메모리 할당 및 관리가 용이하다.
  • 고정 크기 메시지의 단점: 메시지 크기가 다양할 때 낭비되는 메모리가 발생하거나, 메시지를 분할해야 하는 번거로움이 있다.
  • 가변 크기 메시지의 이점: 메시지 크기에 따라 메모리를 효율적으로 활용할 수 있으며, 메시지의 다양한 크기를 처리할 수 있다.
  • 가변 크기 메시지의 단점: 메모리 관리가 복잡해지며, 메시지를 처리하는 데 추가적인 로직이 필요하다.

3.18

Using either a UNIX or a Linux system, write a C program that forks a child process that ultimately becomes a zombie process. This zombie process must remain in the system for at least 10 seconds. Process states can be obtained from the command

ps -l

The process states are shown below the S column; processes with a state of Z are zombies. The process identifier (pid) of the child process is listed in the PID column, and that of the parent is listed in the PPID column.
Perhaps the easiest way to determine that the child process is indeed a zombie is to run the program that you have written in the background (using the &) and then run the command ps -l to determine whether the child is a zombie process. Because you do not want too many zombie processes existing in the system, you will need to remove the one that you have created. The easiest way to do that is to terminate the parent process using the kill command. For example, if the pid of the parent is 4884, you would enter

kill -9 4884

아래는 좀비 프로세스를 생성하는 C 프로그램 예제입니다:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 자식 프로세스
        printf("자식 프로세스 생성 (PID: %d)\n", getpid());
        exit(0);
    } else if (pid > 0) {
        // 부모 프로세스
        printf("부모 프로세스 (PID: %d), 자식 프로세스 (PID: %d)\n", getpid(), pid);
        sleep(10); // 10초 동안 대기
    } else {
        // 포크 실패
        perror("fork 실패");
        return 1;
    }

    return 0;
}

프로그램을 컴파일하고 실행한 후 백그라운드에서 작동시키고 ps -l 명령어를 사용하여 좀비 프로세스인지 확인할 수 있습니다. 확인 후에는 위에서 설명한대로 kill 명령어를 사용하여 부모 프로세스를 종료하고 좀비 프로세스를 제거합니다.

앞서 작성한 프로그램을 통해 좀비 프로세스를 생성하는 과정을 이해하였습니다. 이제 다른 관련 주제를 살펴보겠습니다.

  1. 좀비 프로세스에 대한 이해 확장

    좀비 프로세스는 종료되었지만 부모 프로세스에 의해 아직 완전히 회수되지 않은 프로세스입니다. 자식 프로세스가 종료되면 운영 체제는 프로세스에 대한 대부분의 리소스를 해제하지만, 프로세스 테이블에는 프로세스의 종료 상태와 관련된 정보가 남아 있습니다. 부모 프로세스가 이 정보를 요청할 때까지 이러한 정보는 프로세스 테이블에 남습니다. 부모 프로세스가 wait() 함수를 호출하여 자식 프로세스의 상태를 확인하고, 프로세스 테이블에서 해당 정보를 제거할 때까지 자식 프로세스는 좀비 상태에 머무릅니다.

  2. 좀비 프로세스 방지

    좀비 프로세스가 시스템에 누적되면 리소스 낭비와 성능 저하 문제가 발생할 수 있습니다. 이를 방지하기 위한 몇 가지 방법이 있습니다.

    • 부모 프로세스가 자식 프로세스를 기다리도록 wait() 또는 waitpid() 함수를 사용합니다. 이를 통해 부모 프로세스는 자식 프로세스의 종료 상태를 얻고, 시스템은 종료된 자식 프로세스의 리소스를 완전히 해제할 수 있습니다.
    • 자식 프로세스가 먼저 종료되는 경우, 부모 프로세스가 종료되기 전에 시그널 핸들러를 사용하여 wait() 또는 waitpid() 함수를 호출할 수 있습니다. 이 방법으로 자식 프로세스의 상태를 확인하고, 좀비 프로세스가 되지 않도록 할 수 있습니다.
  3. 좀비 프로세스와 고아 프로세스

    좀비 프로세스와 관련된 또 다른 개념은 고아 프로세스입니다. 고아 프로세스는 부모 프로세스가 종료되어 더 이상 관리되지 않는 자식 프로세스를 의미합니다. 이러한 경우 운영 체제는 init 프로세스(또는 systemd)가 고아 프로세스의 새로운 부모 프로세스가 되도록 합니다. init 프로세스는 자동으로 wait() 함수를 호출하여 고아 프로세스가 종료될 때 종료 상태를 회수하고, 좀비 프로세스가 되지 않도록합니다. 따라서, init 프로세스의 역할은 시스템에서 고아 프로세스를 관리하고, 좀비 프로세스를 방지하는 것입니다.

  4. 좀비 프로세스 및 고아 프로세스 처리의 중요성

    좀비 프로세스와 고아 프로세스를 적절하게 처리하지 않으면 시스템 리소스가 낭비되고 성능이 저하될 수 있습니다. 프로세스 테이블에 불필요한 항목이 쌓이면 프로세스 생성에 필요한 공간이 부족해질 수 있으며, 이로 인해 시스템이 새 프로세스를 생성하지 못하게 됩니다.

    따라서, 프로그래머는 다음과 같은 사항을 염두에 두어야 합니다.

    • 부모 프로세스는 자식 프로세스의 상태를 체크하고, wait() 또는 waitpid() 함수를 사용하여 종료된 자식 프로세스의 리소스를 회수해야 합니다.
    • 시그널 핸들러를 사용하여 부모 프로세스가 종료되기 전에 자식 프로세스의 상태를 체크하고, 종료된 자식 프로세스의 리소스를 회수할 수 있습니다.
    • 고아 프로세스의 관리는 운영 체제의 init 프로세스(또는 systemd)가 담당하므로, 프로그래머는 고아 프로세스가 발생하지 않도록 주의해야 합니다.

이러한 사항을 고려하여 프로그램을 작성하면, 시스템 리소스를 효율적으로 활용할 수 있으며, 시스템 성능 저하를 방지할 수 있습니다. 이를 통해 안정적이고 효율적인 멀티태스킹 환경을 구축할 수 있습니다.


3.19

Write a C program called time.c that determines the amount of time necessary to run a command from the command line. This program will be run as "./time <command>" and will report the amount of elapsed time to run the specified command. This will involve using fork() and exec() functions, as well as the gettimeofday() function to determine the elapsed time. It will also require the use of two different IPC mechanisms.
The general strategy is to fork a child process that will execute the specified command. However, before the child executes the command, it will record a timestamp of the current time (which we term “starting time”). The parent process will wait for the child process to terminate. Once the child terminates, the parent will record the current timestamp for the ending time. The difference between the starting and ending times represents the elapsed time to execute the command. The example output below reports the amount of time to run the command ls :

	./time ls
	time.c
	time

	Elapsed time: 0.25422

As the parent and child are separate processes, they will need to arrange how the starting time will be shared between them. You will write two versions of this program, each representing a different method of IPC.
The first version will have the child process write the starting time to a region of shared memory before it calls exec(). After the child process terminates, the parent will read the starting time from shared memory. Refer to Section 3.7.1 for details using POSIX shared memory. In that section, there are separate programs for the producer and consumer. As the solution to this problem requires only a single program, the region of shared memory can be established before the child process is forked, allowing both the parent and child processes access to the region of shared memory.
The second version will use a pipe. The child will write the starting time to the pipe, and the parent will read from it following the termination of the child process.
You will use the gettimeofday() function to record the current timestamp. This function is passed a pointer to a struct timeval object, which contains two members: tv_sec and t_usec. These represent the number of elapsed seconds and microseconds since January 1, 1970 (known as the UNIX EPOCH). The following code sample illustrates how this function can be used:

	struct timeval current;

    gettimeofday(&current,NULL);

    // current.tv_sec represents seconds
    // current.tv_usec represents microseconds

For IPC between the child and parent processes, the contents of the shared memory pointer can be assigned the struct timeval representing the starting time. When pipes are used, a pointer to a struct timeval can be written to—and read from—the pipe.

이 문제를 해결하기 위해 먼저 공유 메모리를 사용하는 버전을 작성하겠습니다. 그 다음 파이프를 사용하는 버전을 작성할 예정입니다.

  1. 공유 메모리를 사용하는 버전:
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>

int main(int argc, char *argv[]) {
    if (argc < 2) {
        printf("Usage: %s <command>\n", argv[0]);
        exit(1);
    }

    struct timeval *start_time;
    start_time = mmap(NULL, sizeof(struct timeval), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);

    pid_t pid = fork();
    if (pid < 0) {
        perror("Fork failed");
        exit(1);
    } else if (pid == 0) {
        gettimeofday(start_time, NULL);
        execlp(argv[1], argv[1], NULL);
        perror("Exec failed");
        exit(1);
    } else {
        int status;
        wait(&status);
        struct timeval end_time;
        gettimeofday(&end_time, NULL);

        double elapsed_time = (end_time.tv_sec - start_time->tv_sec) + (end_time.tv_usec - start_time->tv_usec) / 1000000.0;
        printf("Elapsed time: %.5f\n", elapsed_time);
    }

    return 0;
}
  1. 파이프를 사용하는 버전:
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>

int main(int argc, char *argv[]) {
    if (argc < 2) {
        printf("Usage: %s <command>\n", argv[0]);
        exit(1);
    }

    int pipe_fd[2];
    if (pipe(pipe_fd) < 0) {
        perror("Pipe creation failed");
        exit(1);
    }

    pid_t pid = fork();
    if (pid < 0) {
        perror("Fork failed");
        exit(1);
    } else if (pid == 0) {
        close(pipe_fd[0]);
        struct timeval start_time;
        gettimeofday(&start_time, NULL);
        write(pipe_fd[1], &start_time, sizeof(struct timeval));
        close(pipe_fd[1]);

        execlp(argv[1], argv[1], NULL);
        perror("Exec failed");
        exit(1);
    } else {
        close(pipe_fd[1]);
        int status;
        wait(&status);
        struct timeval start_time;
        read(pipe_fd[0], &start_time, sizeof(struct timeval));
        close(pipe_fd[0]);

        struct timeval end_time;
        gettimeofday(&end_time, NULL);

        double elapsed_time = (end_time.tv_sec - start_time.tv_sec) + (end_time.tv_usec - start_time.tv_usec) / 1000000.0;
        printf("Elapsed time: %.5f\n", elapsed_time);
    }

    return 0;
}

위의 두 프로그램은 주어진 요구 사항을 충족합니다. 첫 번째 프로그램은 공유 메모리를 사용하여 시작 시간을 전달하고, 두 번째 프로그램은 파이프를 사용하여 시작 시간을 전달합니다. 이 프로그램들은 각각 주어진 명령을 실행하는 데 걸린 시간을 측정하고 출력합니다.

프로그램을 컴파일하고 실행하려면 다음 단계를 수행하세요:

  1. 소스 코드를 각각 time_shared_memory.ctime_pipe.c라는 파일에 저장하세요.
  2. 각 소스 파일을 컴파일하려면 터미널에서 다음 명령을 실행하세요.
gcc -o time_shared_memory time_shared_memory.c
gcc -o time_pipe time_pipe.c
  1. 이제 프로그램을 실행할 수 있습니다. 예를 들어, ls 명령을 실행하는 데 걸린 시간을 측정하려면 다음과 같이 입력하세요.
./time_shared_memory ls
./time_pipe ls

위 프로그램은 주어진 명령을 실행한 후 실행 시간을 측정하고 출력합니다. 프로그램을 통해 다양한 명령을 실행할 수 있으며, 각각 공유 메모리 및 파이프를 사용하여 시작 시간을 전달하는 방법을 확인할 수 있습니다. 이 두 가지 IPC 메커니즘이 모두 효과적으로 작동함을 알 수 있습니다.


3.20

An operating system’s pid manager is responsible for managing process identifiers. When a process is first created, it is assigned a unique pid by the pid manager. The pid is returned to the pid manager when the process completes execution, and the manager may later reassign this pid. Process identifiers are discussed more fully in Section 3.3.1. What is most important here is to recognize that process identifiers must be unique; no two active processes can have the same pid.
Use the following constants to identify the range of possible pid values:

#define MIN PID 300
#define MAX PID 5000

You may use any data structure of your choice to represent the availability of process identifiers. One strategy is to adopt what Linux has done and use a bitmap in which a value of 0 at position i indicates that a process id of value i is available and a value of 1 indicates that the process id is currently in use.
Implement the following API for obtaining and releasing a pid:

  • int allocate map(void)—Creates and initializes a data structure for representing pids; returns −1 if unsuccessful, 1 if successful
  • int allocate pid(void)—Allocates and returns a pid; returns −1 if unable to allocate a pid (all pids are in use)
  • void release pid(int pid)—Releases a pid
    This programming problem will be modified later on in Chapter 4 and in Chapter 6.

이 프로그래밍 문제의 해결 방법은 다음과 같습니다. 먼저 pid를 나타내는 데이터 구조를 생성하고 초기화하는 함수와 pid를 할당하고 반환하는 함수, 그리고 pid를 해제하는 함수를 구현해야 합니다. 비트맵을 사용하여 이를 구현할 수 있습니다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MIN_PID 300
#define MAX_PID 5000

unsigned char pid_map[(MAX_PID - MIN_PID) / 8 + 1];

int allocate_map(void) {
    memset(pid_map, 0, sizeof(pid_map));
    return 1;
}

int allocate_pid(void) {
    for (int i = 0; i <= (MAX_PID - MIN_PID) / 8; i++) {
        for (int j = 0; j < 8; j++) {
            int bit = 1 << j;
            if (!(pid_map[i] & bit)) {
                pid_map[i] |= bit;
                return i * 8 + j + MIN_PID;
            }
        }
    }
    return -1;
}

void release_pid(int pid) {
    if (pid < MIN_PID || pid > MAX_PID) {
        return;
    }

    int index = (pid - MIN_PID) / 8;
    int bit = 1 << ((pid - MIN_PID) % 8);
    pid_map[index] &= ~bit;
}

int main() {
    if (allocate_map() == 1) {
        printf("PID map successfully allocated.\n");
    } else {
        printf("Failed to allocate PID map.\n");
        return 1;
    }

    int pid1 = allocate_pid();
    int pid2 = allocate_pid();
    int pid3 = allocate_pid();

    printf("Allocated PIDs: %d, %d, %d\n", pid1, pid2, pid3);

    release_pid(pid2);

    int pid4 = allocate_pid();
    printf("Allocated PID: %d\n", pid4);

    return 0;
}

위 코드에서 pid_map은 비트맵을 나타냅니다. allocate_map() 함수는 pid_map을 0으로 초기화하여 모든 pid가 사용 가능한 상태로 설정합니다. allocate_pid() 함수는 사용 가능한 pid를 찾아 할당하고 해당 비트를 1로 설정한 후 pid를 반환합니다. 만약 모든 pid가 사용 중이라면 -1을 반환합니다. release_pid() 함수는 주어진 pid의 비트를 0으로 설정하여 pid를 해제합니다.

main() 함수에서는 예시로 이 함수들을 호출하여 pid를 할당하고 해제하는 과정을 보여줍니다.


References

  • GPT4
profile
🧑‍💻 SOFTWARE ENGINEER. 무해를 지향합니다. 편견을 지양합니다.

0개의 댓글