Skip to content

언리얼 렌더링 시스템 분석(18) - 운영 체제

🌐 원문 링크: 剖析虚幻渲染体系(18)- 操作系统 (cnblogs.com/timlly)
📅 원문 발행일: 2022-10-31 | ✍️ 저자: Timlly (00 / )

💡 시리즈 분류: Unreal Engine Rendering국내 업계 표준 용어 감수 및 수식/도해 복원 적용 완료


지금까지 블로거들이 블로그에 설명하는 콘텐츠에는 렌더링 기술, 성능 최적화, 그래픽 API, 셰이더, GPU, 게임 엔진 아키텍처, 그래픽 드라이버 등과 같은 기술 콘텐츠가 포함됩니다. 이러한 콘텐츠는 단일 애플리케이션에만 국한되어 있어 사람들이 "이 산에만 있다"는 느낌을 갖게 되는 경우가 많습니다. 이제 한 단계 더 나아가 운영 체제(OS)라는 범주에 들어가 더 높은 수준의 렌더링 시스템을 살펴보고 탄탄한 기반을 구축하고 심층적인 기술력을 개발할 때입니다.

이 기사에서는 애플리케이션 계층 개발자의 관점에서 Windows 및 Linux와 같은 운영 체제의 관련 기술 내부 정보를 설명합니다(운영 체제 개발자라면 블로거는 이 내용이 대상 독자에게 매우 적합하다고 생각하지 않습니다). 여기에는 주로 다음 내용이 포함되지만 이에 국한되지는 않습니다.

  • 운영 체제의 아키텍처.

  • 운영체제의 개념.

  • 운영 체제 기술.

  • 운영체제의 메커니즘과 원리.

  • UE 캡슐화 및 운영 체제 구현.

운영 체제는 수십 년의 개발을 거쳤습니다. 초기 형태부터 현재의 눈부신 다양성까지 기술, 경험, 적용 측면에서 질적인 도약이 이루어졌습니다.

UNIX 운영체제는 1970년대에 처음 개발되었으며 동시에 여러 사용자를 지원하는 멀티태스킹 운영체제이다. 수천 개의 작은 프로그램을 동시에 실행하기 위한 명령줄 기반 지원, 단일 프로그램에서 파이프라인을 쉽게 생성, 내장된 다중 사용자 지원 및 파티셔닝 기능을 갖추고 있습니다. 문제는 명령줄 기반이며 다양한 변형으로 인해 도움말과 문서를 찾는 것이 지루할 수 있습니다(아래 이미지).

Unix 시스템 및 그 파생 제품.

Windows, MacOS, Android 및 Linux와 같은 운영 체제도 인기 있고 잘 알려져 있습니다.

18.1.1 운영 체제 기능

운영 체제는 애플리케이션과 하드웨어 사이에 위치하여 액세스를 중재하고 인터페이스를 추상화합니다. 프로그램은 트랩이나 예외를 통해 서비스를 요청하고, 장치는 인터럽트를 통해 주의를 요청합니다. 운영 체제는 특히 다음과 같은 다양한 기능을 수행합니다.

  • 사용자 인터페이스를 구현합니다.

  • 사용자 간 하드웨어를 공유합니다.

  • 사용자 간 데이터 공유를 허용합니다.

  • 사용자가 서로 간섭하는 것을 방지합니다.

  • 사용자 간의 리소스 예약.

  • I/O 작업을 용이하게 합니다.

  • 오류에서 복구합니다.

  • 자원 저장 회계.

  • 병렬 작업을 촉진합니다.

  • 안전하고 빠른 액세스를 위해 데이터를 구성합니다.

  • 네트워크 통신을 처리합니다.

운영 체제는 응용 프로그램의 실행을 제어하고 컴퓨터 사용자와 컴퓨터 하드웨어 사이의 중개자 역할을 하는 프로그램 집합입니다. 운영 체제는 컴퓨터 하드웨어를 관리하고 응용 프로그램이 실행될 수 있는 환경을 제공하는 소프트웨어입니다. 예로는 Windows, Windows/NT, Linux, OS/2 및 MacOS가 있습니다. OS에서 다루는 주제는 다음과 같습니다.

운영 체제는 다음과 같은 주요 기능 범주를 제공합니다.

  • 작업 관리: 작업 식별, 우선 순위 결정, 주 메모리 가용성 결정, 작업 또는 프로그램 실행 일정 준비.

  • 프로세스 관리: 컴퓨터 시스템 프로세서 및 입출력 장치의 유휴 시간을 줄입니다.

  • 입력/출력 관리: 다양한 장치와 해당 드라이버의 도움으로 컴퓨터의 입력 및 출력 스트림을 관리합니다.

  • 데이터 관리: 디스크 및 기타 저장 장치의 데이터를 추적합니다.

  • 파일 관리 : 파일 및 디렉터리 생성, 삭제, 운영 등 파일 관련 활동을 담당합니다.

  • 메모리 관리: 다양한 프로그램의 필요에 따라 사용 가능한 메모리를 할당하고 해제합니다.

  • 가상 스토리지: 가상 메모리를 사용하여 명령어와 데이터를 저장함으로써 물리적 크기를 늘리지 않고도 메인 메모리의 용량을 늘립니다.

  • 보안 관리: 중요한 데이터에 대한 비밀번호 보안을 제공하여 무단 액세스를 방지합니다.

  • 온라인 처리: 사용자의 작업 지시를 기다렸다가 즉시 실행합니다.

공통 모듈 또는 하위 시스템에는 메모리 관리, IO 시스템, 파일 시스템, 프로세스, 스레드, 인터럽트, 보안, 정보 서비스 등이 포함됩니다. 이들 사이에는 복잡한 관계가 있습니다.

운영 체제의 목표는 다음과 같습니다. 1. 컴퓨터 시스템을 사용자 친화적으로 만듭니다. 2. 컴퓨터 하드웨어를 효율적으로 사용하기 위해 3. 사용자 프로그램을 실행하고 사용자 문제를 보다 쉽게 ​​해결하기 위해.

운영 체제는 각각 사용자 관점과 시스템 관점에서 볼 수 있습니다.

컴퓨터의 사용자 보기는 사용된 인터페이스에 따라 다릅니다. 일부 사용자는 PC를 사용할 수 있으며, 이 경우 시스템은 한 명의 사용자만 리소스를 활용할 수 있도록 설계되었으며, 주로 사용 편의성을 위해 리소스 활용보다는 성능이 주요 관심사입니다. 일부 사용자는 메인프레임이나 미니컴퓨터에 연결된 단말기를 사용할 수도 있고, 다른 사용자는 다른 단말기를 통해 동일한 컴퓨터에 접속할 수도 있으며, 이들 사용자는 리소스를 공유하고 정보를 교환할 수 있습니다. 이 경우 운영 체제는 사용 가능한 모든 CPU 시간, 메모리 및 I/O를 효율적으로 활용하기 위해 리소스 활용도를 최대화하도록 설계되었습니다. 다른 사용자는 다른 워크스테이션과 서버의 네트워크에 연결된 워크스테이션에 앉아 있을 수 있으며, 이 경우 운영 체제는 개인 가시성과 리소스 활용 사이의 절충안을 제공하도록 설계되었습니다.

시스템 관점에서 시스템은 자원 할당자로 볼 수 있습니다. 즉, 컴퓨터 시스템에는 문제를 해결하는 데 사용할 수 있는 많은 자원이 있습니다. 운영 체제는 이러한 리소스의 관리자 역할을 하며 컴퓨터 시스템을 효율적이고 공정하게 운영하기 위해 이러한 리소스를 프로그램과 사용자에게 할당하는 방법을 결정해야 합니다. 운영 체제의 다른 관점은 다양한 I/O 장치와 사용자 프로그램을 제어해야 한다는 것입니다. 즉, 운영 체제는 컴퓨터의 오류와 부적절한 사용을 방지하기 위해 사용자 프로그램의 실행을 관리하는 데 사용되는 제어 프로그램입니다. 자원은 CPU 시간, 메모리 공간, 파일 저장 공간, I/O 장치 등이 될 수 있습니다.

운영 체제 서비스는 프로그램 실행을 위한 환경을 제공하고 프로그램에 대한 일부 서비스도 제공하는 운영 체제입니다. 각 운영 체제에서 제공하는 서비스는 다른 운영 체제와 다를 수 있으므로 프로그래밍 작업이 더 쉬워집니다.

운영 체제 서비스 개요.

운영체제에서 제공하는 다양한 서비스는 다음과 같습니다.

  • 프로그램 실행: 시스템은 프로그램을 주 메모리 파티션에 로드하고 프로그램을 실행할 수 있어야 하며, 프로그램은 성공적으로 실행하기 위해 이 실행을 정상적으로 종료하거나 오류와 함께 비정상적으로 종료할 수 있어야 합니다.

  • I/O 작업: 장치 할당 및 I/O 장치 제어 작업을 완료하고 장치 오류, 장치 상태 등에 대한 알림을 제공합니다.

  • 파일 시스템 작업: 파일 열기 및 파일 닫기 작업을 완료하려면 프로그램에서 파일 읽기, 파일 쓰기, 파일 추가와 같은 파일 작업을 허용하여 이름별로 파일을 생성 및 삭제해야 합니다.

  • 통신: 동일한 컴퓨터 시스템에서 또는 컴퓨터 네트워크의 서로 다른 컴퓨터 시스템 간에 프로세스 간 통신 작업을 완료하여 안전 모드에서 메시징 및 공유 메모리 액세스를 제공합니다.

  • 오류 감지: 운영 체제는 모든 종류의 오버플로(예: 산술 오버플로)에 대해 적절한 조치를 취해야 합니다. 0으로 나누기 오류, 잘못된 메모리 위치에 대한 액세스 및 과도한 사용자 CPU 시간. 프린터 걸림이나 용지 중단과 같은 오류 감지 및 복구 작업(있는 경우)을 완료합니다. CPU, 메모리, I/O 장치, 저장 장치, 파일 시스템, 네트워크 등의 상태를 추적합니다. RAM 패리티 오류, 전원 변동 등 치명적인 오류가 발생하면 실행이 중단됩니다.

  • 자원 할당: 여러 작업에 자원을 할당하는 작업을 완료하고, 자원이 사용된 후 또는 작업이 종료되면 할당된 자원을 회수합니다. 여러 사용자가 시스템에 로그인하는 경우 각 사용자에게 리소스를 할당해야 합니다. 현재 프로세스 간 리소스 할당을 위해 운영 체제는 CPU 스케줄링 런타임을 사용하여 리소스를 할당할 프로세스를 결정합니다.

  • 계정: 운영 체제는 어떤 사용자가 어떤 컴퓨터 리소스를 얼마나 많이 사용하는지 추적하고 성능 분석 및 오류 복구를 위해 시스템 활동 로그를 유지합니다.

  • 보호: 악의적인 사용으로부터 시스템 리소스를 보호하고, 무단 액세스/사용자가 보안 컴퓨팅을 수행하는 것을 방지하기 위한 보안 솔루션을 채택하고, 로그인 비밀번호 및 등록을 사용하여 합법적인 사용자를 인증하는 작업을 완료합니다. 운영 체제는 하드웨어 및 소프트웨어 보호를 담당합니다. 운영 체제는 다중 사용자 컴퓨터 시스템에 저장된 정보를 보호합니다.

  • 시스템 호출: 시스템 호출은 프로세스와 운영 체제 간의 인터페이스를 제공하며 일반적으로 어셈블리 언어 명령어 형식으로 제공됩니다. 일부 시스템에서는 고급 언어 프로그램(예: C, BCPL, PERL 등)에서 직접 시스템 호출을 수행할 수 있으며, 시스템 호출은 사용하는 컴퓨터에 따라 다른 방식으로 수행됩니다. 시스템 호출은 대략 5가지 범주로 나눌 수 있습니다.

프로세스 관리 및 제어:

종료, 중단: 실행 중인 프로그램은 정상적으로(종료) 또는 비정상적으로(중단) 실행될 수 있어야 합니다.

  • 로드, 실행: 하나의 프로그램을 실행하는 프로세스나 작업이 다른 프로그램을 로드하고 실행할 수 있습니다.

  • 프로세스 생성, 종료 : 새로운 프로세스나 작업을 생성(프로세스 생성 또는 작업 제출)하거나, 생성된 작업이나 프로세스를 종료하기 위해 지정된 시스템 콜이 있다.

  • 프로세스 속성 가져오기 또는 설정: 새 작업이나 프로세스가 생성되면 작업이나 프로세스의 속성을 확인하고 재설정하기 위해 실행을 제어할 수 있어야 합니다.

  • 대기 시간: 새로운 작업이나 프로세스를 생성한 후 실행이 완료될 때까지 기다려야 할 수 있습니다(대기 시간).

  • 대기 이벤트, 신호 이벤트: 특정 이벤트가 발생할 때까지 기다릴 수 있으며(대기 이벤트) 작업이나 프로세스는 해당 이벤트가 발생할 때 신호를 내보냅니다(신호 이벤트).

  • 메모리를 할당하고 해제합니다.

  • 파일 관리/파일 작업/파일 처리:

파일 생성, 파일 삭제: 먼저 파일을 생성하고 삭제할 수 있어야 합니다. 두 시스템 호출 모두 파일 이름과 해당 속성 중 일부가 필요합니다.

  • 파일 열기, 파일 닫기 : 파일을 생성한 후 열어서 사용해야 합니다. 파일이 더 이상 사용되지 않으면 파일을 닫습니다.

  • 파일 읽기, 쓰기 및 재배치: 파일을 열면 파일을 읽거나 쓰거나 재배치할 수도 있습니다(파일 재생 또는 파일 끝으로 이동).

  • 파일 속성 가져오기, 파일 속성 설정: 파일이나 디렉터리의 경우 다양한 속성의 값을 확인하고 필요한 경우 재설정할 수 있어야 합니다. 파일 속성을 가져오고 파일 속성을 설정하려면 두 개의 시스템 호출이 필요합니다.

  • 장치 관리:

장치 요청, 장치 해제: 시스템에 사용자가 여러 명인 경우 장치를 먼저 요청하고 장치 완료 후 장치를 해제해야 합니다.

  • 읽기, 쓰기, 재배치: 장치를 요청하고 할당하면 해당 장치를 읽고 쓰고 재배치할 수 있습니다.

  • 정보 유지/관리:

시간 또는 날짜 가져오기, 시간 또는 날짜 설정: 대부분의 시스템에는 현재 날짜와 시간을 반환하거나 현재 날짜와 시간을 설정하는 시스템 호출이 있습니다.

  • 시스템 데이터 가져오기, 시스템 데이터 설정: 다른 시스템 호출은 현재 사용자 수, 운영 체제 버전 번호, 사용 가능한 메모리 양 등과 같은 시스템에 대한 정보를 반환할 수 있습니다.

  • 프로세스 속성 가져오기, 프로세스 속성 설정: 운영 체제는 모든 프로세스에 대한 정보를 유지하며 이 정보에 액세스하기 위한 시스템 호출이 있습니다.

  • 커뮤니케이션 관리:

연결을 생성하고 삭제합니다.

  • 메시지를 보내고 받습니다.

  • 원격 장치를 연결하고 분리합니다(설치/원격 로그인).

  • 전송 상태 정보(바이트).

통신 관리에는 두 가지 모드가 있습니다.

  • 메시지 전달 모드: 운영 체제에서 제공하는 프로세스 간 통신 기능을 통해 정보가 교환되며, 네트워크의 각 컴퓨터는 알려진 이름을 갖습니다. 마찬가지로, 각 프로세스에는 운영 체제가 참조할 수 있는 동등한 식별자로 변환되는 프로세스 이름이 있습니다. get-host-id 및 get-processed 시스템 호출은 이 변환을 수행하는 데 사용되며, 이러한 식별자는 파일 시스템에서 제공하는 일반 열기 및 닫기 호출이나 특정 열기 연결 시스템 호출에 전달됩니다. 수신자 프로세스에는 연결 수락 호출과 통신할 수 있는 권한이 부여되어야 합니다. 클라이언트라고 하는 통신 소스와 서버라고 하는 수신자는 메시지 읽기 및 쓰기 시스템 호출을 통해 메시지를 교환합니다. 연결 닫기 호출은 연결을 종료합니다.

  • 공유 메모리 모드: 프로세스는 매핑된 메모리 시스템 호출을 사용하여 다른 프로세스가 소유한 메모리 영역에 액세스합니다. 공유 영역에서 데이터를 읽고 쓰며 정보를 교환합니다. 이러한 프로세스는 동시에 동일한 위치에 쓰지 않도록 보장합니다.

18.1.2 운영 체제 유형

운영 체제는 다음 범주로 나눌 수 있습니다.

18.1.2.1 배치 운영 체제

배치 시스템 유형의 운영 체제에서 사용자는 시스템 사용자가 컴퓨터 시스템과 직접 상호 작용하지 않는 중앙 위치에 정기적으로(예: 매일, 매주, 매월) 작업을 제출합니다. 처리 속도를 높이기 위해 요구 사항이 비슷한 작업을 일괄 처리하여 여러 컴퓨터에서 그룹으로 실행합니다. 따라서 프로그래머는 프로그래밍을 운영자에게 맡기고 각 작업의 출력은 해당 프로그래머에게 전송됩니다. 이 유형의 주요 작업은 한 작업에서 다음 작업으로 제어권을 자동으로 전송하는 것입니다.

이 운영 체제의 장점은 간단하고 지속적인 작업 스케줄링, 사람의 개입 최소화, 작업 일괄 처리로 인한 성능 및 시스템 처리량 향상입니다. 단점은 사용자 관점에서 일괄 처리로 인해 처리 시간이 길어질 수 있고, 프로그램 디버깅이 어렵고, 작업이 무한 루프에 빠질 수 있고, 모니터가 손상될 수 있으며, 보호 체계가 부족하여 하나의 작업이 대기 중인 작업에 영향을 줄 수 있다는 점입니다.

적용 사례로는 급여 시스템, 은행 명세서 등이 있습니다. 처리 시간은 사용자가 프로세스나 작업을 제출하는 시점과 완료되는 시점 사이의 시간입니다.

온라인의 정식 명칭은 **SPOOL(Simultaneous Peripheral Operation On-Line)**이며, 장치, 프로그램, 시스템에서 사용하고 실행하기 위해 데이터를 임시로 저장하는 프로세스입니다. 데이터는 다른 휘발성(임시 메모리) 메모리로 전송되어 프로그램이나 컴퓨터가 실행을 요청할 때까지 그곳에 저장됩니다.

18.1.2.2 시분할 운영 체제

시간 공유시간 공유 시스템다중 사용자 시스템이라고도 합니다. 사용자와 시스템 간의 온라인 통신을 제공합니다. 사용자가 직접 지시를 내리고 중간 응답을 받기 때문에 대화형 시스템이라고 합니다. 이를 통해 여러 사용자가 동시에 컴퓨터 시스템을 공유할 수 있으며, CPU는 메모리와 디스크에 저장된 여러 프로그램 사이를 빠르게 다중화하고 하나의 프로그램은 메모리와 디스크의 메모리를 교환합니다.

CPU는 작업 간을 전환하여 여러 작업을 수행하지만 자주 전환하며 사용자는 실행되는 각 프로그램과 상호 작용할 수 있습니다. 대화형 컴퓨터 시스템은 사용자와 시스템 간의 직접적인 통신을 제공하며, 사용자는 키보드나 마우스를 사용하여 운영 체제나 프로그램에 직접 명령을 내리고 즉각적인 결과를 기다립니다. 따라서 응답 시간이 짧습니다. 시분할 시스템을 사용하면 많은 사용자가 동시에 컴퓨터를 공유할 수 있으며, 이 시스템의 각 작업은 짧기 때문에 각 사용자가 CPU 시간을 거의 필요로 하지 않습니다. 시스템은 한 사용자에서 다른 사용자로 빠르게 전환될 수 있으므로 많은 사용자가 공유하더라도 각 사용자는 전체 컴퓨터 시스템이 자신의 용도로만 사용되는 것처럼 느낍니다.

위 이미지에서는 사용자 5가 활성 상태이지만 사용자 1, 사용자 2, 사용자 3, 사용자 4는 대기 상태이고 사용자 6은 준비 상태입니다. 사용자 5의 타임 슬라이스가 완료되면 타임 슬라이스는 다음 준비된 사용자인 사용자 6으로 이동합니다. 이 상태에서 사용자 2, 사용자 3, 사용자 4, 사용자 5는 대기 상태이고, 사용자 1은 준비 상태입니다. 프로세스는 동일한 방식으로 계속됩니다.

시분할 시스템의 장점은 컴퓨터 자원의 효율적인 공유 및 활용, 많은 사용자에 대한 빠른 응답 시간, CPU 유휴 시간의 완전한 제거, 온라인 데이터 처리 및 사용자 대화에 적합하다는 것입니다.

시분할 시스템의 단점은 다중 프로그래밍 운영 체제보다 더 복잡하다는 것입니다. 여러 작업이 동시에 메모리에 보관되므로 시스템에는 메모리 관리 및 보호 기능이 있어야 합니다. 시분할 시스템은 파일 시스템도 제공해야 하므로 디스크 관리가 필요합니다. 복잡한 CPU 스케줄링 체계가 필요한 동시 실행을 위한 메커니즘을 제공합니다. 예: Multics, UNIX 등

18.1.2.3 실시간 운영체제

실시간 시스템은 주요 업무가 적시에 완료될 수 있도록 즉각적인 응답을 제공하는 것이 특징입니다. 이 유형에는 컴퓨터에서 수행할 각 기능에 대해 알려진 최대 시간 제한이 있어야 합니다. 실시간 시스템은 프로세서 작업이나 데이터 흐름에 엄격한 시간 요구 사항이 있을 때 사용됩니다. 실시간 시스템은 특수 애플리케이션에서 제어 장치로 사용될 수 있습니다.

실시간 시스템은 프로세서 작업이나 데이터 흐름에 엄격한 시간 요구 사항이 있을 때 사용됩니다. 과학 실험, 의료 영상 시스템 및 일부 디스플레이 시스템을 제어하는 ​​시스템은 실시간 시스템입니다. 센서는 데이터를 컴퓨터로 전송하고 컴퓨터는 데이터를 분석하고 제어 장치를 조정하여 센서 입력을 수정합니다.

실시간 시스템의 단점은 다음과 같습니다. 실시간 시스템은 시간 제약 내에서 올바른 결과를 반환할 때만 올바르게 실행될 수 있는 것으로 간주되며, 보조 저장 장치가 제한되거나 손실되고, 데이터가 일반적으로 단기 메모리 또는 ROM에 저장되므로 고급 운영 체제 기능이 부족합니다.

실시간 시스템에는 두 가지 유형이 있습니다.

  • 하드 실시간 시스템: 갑작스러운 순간에 발생하는 돌발 작업 등 주요 작업을 시간 내에 완료하도록 보장합니다.

  • 소프트 실시간 시스템: 중요한 작업에 다른 작업보다 우선순위를 부여하고 계산 전에 우선순위를 유지하는 덜 제한적인 실시간 시스템입니다. 이러한 시스템은 하드 실시간 시스템보다 유용성이 더 제한적입니다. 가끔씩 마감일을 놓치는 것은 괜찮습니다.

예: QNX, VX 작업, 디지털 오디오 또는 멀티미디어가 이 범주에 포함되며 프로세서 작동에 엄격한 시간 요구 사항이 있는 특수 목적 운영 체제입니다. 실시간 운영 체제에는 처리가 완료되어야 하며 그렇지 않으면 시스템이 실패하는 잘 정의된 고정 시간 제한이 있습니다. 실시간 시스템은 제한된 시간 내에 올바른 결과가 반환되는 경우에만 제대로 작동할 수 있습니다. 이러한 시스템은 시간이 중요한 매개변수라는 특징이 있습니다.

실시간 운영 체제의 장점:

  • 소비 극대화: 장비와 시스템을 최대한 활용하여 모든 자원에서 더 많은 결과를 얻습니다.

  • 작업 이동: 이러한 시스템에서 작업 이동에 할당된 시간은 매우 적습니다. 예를 들어, 이전 시스템에서는 한 작업을 다른 작업으로 전송하는 데 약 10마이크로초가 걸렸지만 최신 시스템에서는 3마이크로초가 걸립니다.

  • 애플리케이션에 집중: 대기열에 있는 애플리케이션보다는 애플리케이션 실행에 중점을 둡니다.

  • 임베디드 시스템의 실시간 운영 체제: 프로그램 크기가 작기 때문에 RTOS는 전송 및 기타 시스템과 같은 임베디드 시스템에서도 사용할 수 있습니다.

  • 오류 없음: 이러한 유형의 시스템에는 오류가 없습니다.

  • 메모리 할당: 메모리 할당은 이러한 유형의 시스템에서 가장 잘 관리됩니다.

실시간 운영 체제의 단점:

  • 제한된 작업: 동시에 실행되는 작업이 거의 없으며 오류를 피하기 위해 몇 가지 응용 프로그램에 집중되는 경우가 거의 없습니다.

  • 과도한 시스템 리소스 사용: 시스템 리소스가 좋지 않고 비용이 많이 드는 경우도 있습니다.

  • 복잡한 알고리즘: 알고리즘은 설계자가 작성하기에는 매우 복잡하고 어렵습니다.

  • 장치 드라이버 및 인터럽트 신호: 가능한 한 빨리 인터럽트에 응답하려면 특정 장치 드라이버 및 인터럽트 신호가 필요합니다.

  • 스레드 우선순위: 스레드 우선순위를 설정하는 것은 이러한 시스템이 작업을 쉽게 전환하지 않기 때문에 좋은 생각이 아닙니다.

실시간 운영 체제의 예로는 과학 실험, 의료 영상 시스템, 산업 제어 시스템, 무기 시스템, 로봇, 항공 교통 관제 시스템 등이 있습니다.

18.1.2.4 다중 프로그래밍 운영 체제

다중 프로그래밍의 개념은 CPU가 항상 실행할 작업을 갖도록 작업을 구성하여 CPU 활용도를 높입니다. 아래 그림과 같이 운영 체제는 동시에 여러 작업을 메모리에 유지합니다.

이 작업 집합은 작업 풀에 있는 작업의 하위 집합이며, 운영 체제는 메모리에서 작업을 선택하여 실행을 시작하며, 한 작업이 기다려야 할 때 CPU는 단순히 다른 작업으로 전환하는 식입니다.

다중 프로그래밍 운영 체제는 운영 체제가 작업 예약이라는 사용자를 위해 결정을 내리기 때문에 복잡합니다. 여러 작업이 동시에 실행될 준비가 되면 시스템은 그 중 하나를 선택하며 이를 CPU 스케줄링이라고 합니다. 다른 기능으로는 메모리 관리, 장치 및 파일 관리가 있습니다.

다중 프로그래밍 시스템의 장점은 효율적인 리소스 활용(CPU, 메모리, 주변 장치), 낭비되는 CPU 유휴 시간 제거 또는 최소화, 처리량 증가(주어진 시간 간격 동안 실행을 위해 제출된 작업 수에 비해 실행되는 작업 수)입니다.

다중 프로그래밍 시스템의 단점은 프로그램 실행 중에 컴퓨터 시스템과 사용자 상호 작용을 제공하지 않는다는 것입니다. 디스크 기술의 도입으로 이러한 문제가 해결되었습니다. 카드 리더기에서 디스크로 카드를 읽는 대신 이러한 형태의 처리를 온라인이라고 하며 이는 복잡하고 비용이 많이 듭니다.

18.1.2.5 다중 프로세서/병렬/강하게 결합된 시스템

이러한 시스템에는 긴밀하게 통신하고 컴퓨터 버스, 시계, 메모리 및 UNIX, LINUX와 같은 주변 장치를 공유하는 여러 프로세서가 있습니다. 다중 프로세서 시스템에는 세 가지 주요 이점이 있습니다.

  • 처리량 증가: 단위 시간당 계산된 프로세스 수입니다. 프로세서 수를 늘리면 작업 시간을 단축할 수 있습니다. N 프로세서의 속도 향상 비율은 N이 아니라 N보다 작습니다. 왜냐하면 모든 것이 제대로 작동하도록 유지하는 데 일정한 오버헤드가 있기 때문입니다.

  • 향상된 신뢰성: 기능이 여러 프로세서에 걸쳐 올바르게 분산될 수 있다면 프로세서 하나에 오류가 발생해도 시스템이 중지되지 않고 속도가 느려집니다. 장애에도 불구하고 계속 작동할 수 있는 이러한 능력은 시스템의 내결함성을 높여줍니다.

  • 경제적인 확장: 다중 프로세서 시스템은 주변 장치, 스토리지 및 전력을 공유할 수 있으므로 비용을 절약합니다.

다중 처리 시스템의 유형은 다음과 같습니다.

  • SMP(대칭적 다중 처리): 각 프로세서는 동일한 운영 체제 복사본을 실행하며 이러한 복사본은 필요에 따라 서로 통신합니다. 예: 다중 최대 컴퓨터용 UNIX Encore 버전. 이제 Windows NT, Solaris, Digital UNIX, OS/2 및 LINUX를 포함한 거의 모든 최신 운영 체제가 SMP를 지원합니다.

  • 비대칭 다중 처리(마스터 – 슬레이브 프로세서): 각 프로세서는 특정 작업을 위해 설계되었습니다. 마스터 프로세서는 시스템을 제어하고 작업을 예약하고 슬레이브 프로세서에 배포합니다. Ex-Sun의 운영 체제 SUNOS 버전 4는 비대칭 다중 처리를 제공합니다.

18.1.2.6 분산/느슨하게 결합된 시스템

긴밀하게 결합된 시스템과 달리 프로세서는 메모리나 클럭을 공유하지 않습니다. 대신 각 프로세서에는 자체 로컬 메모리가 있습니다. 프로세서들은 다양한 통신 회선(고속 버스나 전화선 등)을 통해 서로 통신하며, 분산 시스템의 기능은 네트워크에 따라 달라집니다. 분산 시스템은 통신을 통해 컴퓨팅 작업을 공유하고 사용자에게 풍부한 기능 세트를 제공할 수 있습니다. 네트워크는 사용되는 프로토콜, 노드 간 거리, 전송 매체에 따라 달라집니다.

TCP/IP는 가장 일반적인 네트워크 프로토콜입니다. 분산 시스템의 프로세서는 크기와 기능이 다양하며 마이크로프로세서, 워크스테이션, 미니컴퓨터, 대형 범용 컴퓨터 등이 될 수 있습니다. 네트워크 유형은 LAN(방, 층 또는 건물 내) 및 WAN(건물, 도시 또는 국가 간)과 같은 노드 간의 거리를 기반으로 합니다.

분산 운영 체제의 장점: 리소스 공유, 가속화된 컴퓨팅, 로드 공유/로드 밸런싱, 안정성, 통신.

분산 운영 체제의 단점: 메인 네트워크의 장애로 인해 전체 통신이 중단되고, 분산 시스템을 구축하는 데 사용되는 언어가 잘 정의되어 있지 않으며, 이러한 유형의 시스템은 매우 비싸기 때문에 쉽게 사용할 수 없습니다. 기본 소프트웨어가 매우 복잡할 뿐만 아니라 잘 이해되지도 않습니다. 예로는 LOCUS 등이 있습니다.

18.1.2.7 네트워크 운영 체제

이러한 시스템은 서버에서 실행되며 데이터, 사용자, 그룹, 보안, 애플리케이션 및 기타 네트워크 기능을 관리하는 기능을 제공합니다. 이러한 유형의 운영 체제를 사용하면 소규모 개인 네트워크 공유를 통해 파일, 프린터, 보안, 응용 프로그램 및 기타 네트워크 기능에 액세스할 수 있습니다. 네트워크 운영 체제의 또 다른 중요한 측면은 모든 사용자가 기본 구성, 네트워크에 있는 다른 모든 사용자의 구성, 개인 연결 등을 알고 있다는 것입니다. 이것이 바로 이러한 컴퓨터를 강결합 시스템이라고 부르는 이유입니다.

네트워크 운영 체제의 장점: 매우 안정적인 중앙 집중식 서버, 보안 문제는 서버를 통해 처리되고, 새로운 기술 및 하드웨어 업그레이드가 시스템에 쉽게 통합되며, 서버는 다양한 위치 및 유형의 시스템에서 원격으로 액세스할 수 있습니다. 네트워크 운영 체제의 단점: 서버는 비용이 많이 들고 사용자는 대부분의 작업을 중앙 위치에 의존해야 하며 정기적인 유지 관리 및 업데이트가 필요합니다.

네트워크 운영 체제의 예로는 Microsoft Windows Server 2003, Microsoft Windows Server 2008, UNIX, Linux, Mac OS X, Novell NetWare 및 BSD 등이 있습니다.

18.1.3 운영 체제 구조

운영 체제 아키텍처의 설계는 전통적으로 관심사 분리 원칙을 따랐습니다. 이는 운영 체제를 간단한 단일 기능을 제공하는 상대적으로 독립적인 부분으로 구성하여 설계의 복잡성을 관리 가능하게 유지하는 것을 권장합니다.

18.1.3.1 간단한 구조

작고 단순하며 제한된 시스템으로 시작하여 원래 범위 이상으로 확장되는 운영 체제와 같이 잘 정의된 구조를 갖지 않는 여러 상용 시스템이 있습니다. MS-DOS는 신중하게 모듈로 나누어지지 않은 시스템의 한 예입니다. 제한된 구조의 또 다른 예는 CPU 실행 모드(사용자 및 커널)가 없으므로 응용 프로그램의 버그로 인해 전체 시스템이 중단될 수 있는 UNIX 운영 체제입니다.

지)

18.1.3.2 모놀리식 구조

이 모델에는 모든 시스템 호출에 대해 이를 처리하고 실행하는 서비스 프로세스가 있습니다. 유틸리티는 사용자 프로그램에서 데이터를 얻는 등 여러 서비스 프로그램에 필요한 작업을 수행합니다. 프로그램은 아래 그림과 같이 3개의 레이어로 나누어져 있습니다.

운영 체제 아키텍처의 모놀리식 디자인은 운영 체제의 특수한 특성에 적합하지 않습니다. 디자인은 관심사 분리를 따르지만 운영 체제의 개별 부분에 부여되는 권한을 제한하려는 시도는 없으며 전체 운영 체제는 최대 권한으로 실행됩니다.

모놀리식 운영 체제 내의 통신 오버헤드는 다른 소프트웨어 내의 통신 오버헤드와 동일하며 상대적으로 낮은 것으로 간주됩니다. CP/M과 DOS는 모놀리식 운영 체제의 간단한 예입니다. CP/M과 DOS는 모두 응용 프로그램과 단일 주소 공간을 공유하는 운영 체제입니다. CP/M에서 16비트 주소 공간은 시스템 변수 및 응용 프로그램 영역으로 시작하여 운영 체제의 세 부분, 즉 CCP(콘솔 명령 프로세서), BDOS(기본 디스크 운영 체제) 및 BIOS(기본 입출력 시스템)로 끝납니다. DOS에서 20비트 주소 공간은 인터럽트 벡터 배열과 시스템 변수로 시작하여 DOS의 상주 부분과 응용 프로그램 영역, 마지막으로 비디오 카드와 BIOS에서 사용하는 메모리 블록으로 구성됩니다.

18.1.3.3 계층구조

계층화된 접근 방식에서는 운영 체제가 여러 계층(레벨)으로 나누어지며, 각 계층은 하위 계층에 구축됩니다. 맨 아래 레이어(레이어 0)는 하드웨어이고 맨 위 레이어(레이어 N)는 사용자 인터페이스입니다.

운영 체제를 위한 계층화된 공통 아키텍처입니다.

계층형 접근 방식의 주요 장점은 각 사용자가 하위 수준의 기능(또는 작업)과 서비스만 갖도록 계층을 선택한 모듈성입니다. 이 접근 방식은 디버깅 및 시스템 검증을 단순화합니다. 즉, 시스템의 나머지 부분을 고려할 필요 없이 첫 번째 계층을 디버깅할 수 있습니다. 첫 번째 계층을 디버깅한 후에는 두 번째 계층을 디버깅할 때 제대로 작동한다고 가정합니다. 특정 계층을 디버깅하는 동안 오류가 발견되면 그 아래 계층이 이미 디버깅되었으므로 해당 계층에 오류가 있어야 합니다.

이러한 방식으로 시스템이 여러 레벨로 분해되면 시스템의 설계 및 구현이 단순화됩니다. 각 계층은 하위 계층에서 제공하는 작업만을 사용하여 구현되며 계층은 이러한 작업이 어떻게 구현되는지 알 필요가 없습니다. 이러한 작업이 수행하는 작업만 알면 됩니다. 계층화된 접근 방식은 운영 체제에서 처음 사용되었습니다. 6개의 레이어로 정의됩니다.

레이어 기능

5 사용자 프로그램

4 I/O 관리

3 운영 프로세스 커뮤니케이션

2 메모리 관리

1 CPU 스케줄링

0 하드웨어

계층형 접근 방식의 주요 단점:

  1. 레이어는 그 아래의 레이어만 사용할 수 있으므로 레이어를 신중하게 정의하는 것이 가장 어려운 점입니다. 예를 들어 가상 메모리 알고리즘에서 사용하는 디스크 공간용 장치 드라이버는 메모리 관리 루틴보다 낮은 수준에 있어야 합니다. 메모리 관리에는 디스크 공간 사용 기능이 필요하기 때문입니다.

  2. 비계층 시스템보다 효율성이 낮습니다(각 계층은 시스템 호출의 오버헤드를 증가시키고 최종 결과는 비계층 시스템보다 시스템 호출이 더 오래 걸립니다).

그 중 유닉스 시스템의 아키텍처는 아래 그림과 같다.

유닉스는 커널 프로그램과 시스템 프로그램으로 나눌 수 있다. Unix 커널에는 CPU 스케줄링, 파일 시스템, 메모리 관리 및 I/O 관리와 같은 시스템 리소스 관리, 인터페이스 및 장치 드라이버가 포함되어 있습니다.

Linux는 Intel 386/486/Pentium 시스템을 위한 완전한 Unix 클론입니다. 컴퓨터 시스템의 하드웨어와 소프트웨어 간의 통신 서비스 역할을 합니다. 커널에는 모든 운영 체제에서 기대되는 모든 기능이 포함되어 있습니다. 일부 기능은 다음과 같습니다.

  • 멀티태스킹(여러 개의 독립적인 작업 간에 단일 프로세서를 공유하는 기술).

  • 가상 메모리(성능 향상을 위해 컴퓨터 RAM을 반복적으로 확장하여 사용할 수 있음)

  • 고속 TCP/IP 드라이버(빠른 통신용).

  • 공유 라이브러리(응용 프로그램이 공통 코드를 공유할 수 있도록 함)

  • 다중 사용자 기능(수백 명의 사람들이 네트워크, 인터넷, 랩톱/컴퓨터 또는 해당 컴퓨터의 직렬 포트에 연결된 터미널을 통해 동시에 컴퓨터를 사용할 수 있음을 의미).

  • 보호 모드(프로그램이 실제 메모리에 액세스할 수 있도록 허용하고 시스템 안정성을 보호합니다).

Windows XP는 표준 기반 보안, 관리 용이성, 안정성 등 Windows 2000의 장점과 플러그 앤 플레이, 사용하기 쉬운 사용자 인터페이스 등 Windows 98 및 Windows Me의 최고 기능을 통합한 향상된 기술을 기반으로 하는 멀티태스킹 운영 체제입니다. Windows XP의 아키텍처는 아래 그림과 같습니다. 계층 구조를 채택하고 하드웨어 추상화 계층, 커널 계층, 실행 계층, 사용자 모드 계층 및 애플리케이션으로 구성됩니다.

Windows XP의 각 커널 엔터티는 개체로 처리되며 실행기의 개체 관리자에 의해 관리됩니다. 사용자 모드 응용 프로그램은 프로세스의 개체 핸들을 통해 커널 개체를 호출할 수 있습니다. 커널 개체를 사용하여 기본 서비스를 제공하고 클라이언트-서버 컴퓨팅을 지원하면 Windows XP에서 다양한 응용 프로그램을 지원할 수 있습니다. Windows XP는 또한 가상 메모리, 통합 캐싱, 선점형 예약, 더 강력한 보안 모드 및 국제화 기능을 제공합니다.

Windows 및 Windows Vista 아키텍처 다이어그램.

18.1.3.4 마이크로커널 구조

운영 체제는 커널의 중요하지 않은 부분을 모두 제거하고 이를 시스템 수준 및 사용자 수준 프로그램으로 구현하여 구축되며 일반적으로 최소한의 프로세스와 메모리 관리 및 통신 기능을 제공합니다. 운영 체제 구성 요소 간의 통신은 메시지 전달을 통해 제공됩니다.

마이크로커널의 장점은 운영 체제 확장이 훨씬 쉬워지고, 커널이 더 작기 때문에 커널에 대한 변경이 줄어들고, 마이크로커널이 더 높은 보안과 안정성을 제공한다는 것입니다. 가장 큰 단점은 메시지 전달로 인한 시스템 오버헤드 증가로 인해 성능이 저하된다는 것입니다.

MINIX 3 마이크로커널에는 인터럽트 잡기 및 프로세스 전환과 같은 매우 낮은 수준의 기능을 위한 약 12,000줄의 C 언어와 1,400줄의 어셈블리 언어만 있습니다. C 코드는 프로세스를 관리 및 예약하고 프로세스 간 통신을 처리하며(프로세스 간에 메시지를 전달하여) 운영 체제의 나머지 부분이 해당 작업을 수행할 수 있도록 약 40개의 커널 호출 세트를 제공합니다. 이러한 호출은 핸들러를 인터럽트에 연결하고, 주소 공간 간에 데이터를 이동하고, 새 프로세스를 위한 메모리 맵을 설치하는 등의 기능을 수행합니다. MINIX 3의 프로세스 구조는 아래 그림에 나와 있으며 커널 호출 핸들러에는 Sys라는 라벨이 붙어 있습니다. 스케줄러가 커널과 긴밀하게 상호 작용하기 때문에 시계용 장치 드라이버도 커널에 있습니다. 다른 장치 드라이버는 별도의 사용자 프로세스로 실행됩니다.

Solaris 구조 다이어그램은 다음과 같습니다.

18.1.3.5 클라이언트-서버 구조

마이크로커널 아이디어의 약간 변형은 두 가지 유형의 프로세스, 즉 서버(각각 일부 서비스를 제공)와 클라이언트(이러한 서비스를 사용)를 구별하는 것입니다. 이 모델을 클라이언트-서버 모델이라고 합니다. 일반적으로 맨 아래 계층은 마이크로커널(필수는 아님)이며 그 본질은 클라이언트 프로세스와 서버 프로세스의 존재입니다.

클라이언트와 서버 간의 통신은 일반적으로 메시징을 통해 이루어집니다. 서비스를 얻기 위해 클라이언트 프로세스는 원하는 내용을 설명하는 메시지를 구성하고 이를 적절한 서비스로 보냅니다. 그런 다음 서비스는 작업을 완료하고 답변을 반환합니다. 클라이언트와 서버가 동일한 시스템에서 실행되는 경우 메시징과 같은 특정 최적화가 수행될 수 있습니다.

이 아이디어의 명백한 일반화는 아래 그림과 같이 LAN 또는 WAN으로 연결된 서로 다른 컴퓨터에서 클라이언트와 서버를 실행하는 것입니다. 클라이언트는 메시지를 보내 서버와 통신하므로 메시지가 자체 컴퓨터에서 로컬로 처리되는지 아니면 네트워크를 통해 원격 컴퓨터의 서버로 전송되는지 알 필요가 없습니다. 클라이언트에 관한 한 두 경우 모두 동일한 일이 발생합니다. 즉, 요청을 보낸 다음 응답합니다. 따라서 클라이언트-서버 모델은 단일 시스템이나 시스템 네트워크에서 사용할 수 있는 추상화입니다.

18.1.3.6 가상 머신

가상 머신과 관련된 개념.

가상 머신은 논리적 결론에 대한 계층적 접근 방식을 취하여 하드웨어 및 운영 체제 커널을 하드웨어로 취급하고 기본 베어 하드웨어와 동일한 인터페이스를 제공합니다. 운영 체제는 각각 자체 (가상) 메모리를 사용하여 자체 프로세서에서 실행되고 물리적 컴퓨터의 리소스를 공유하여 가상 머신을 생성하는 여러 프로세스의 환상을 만듭니다. CPU 스케줄링은 사용자가 자신만의 프로세서를 갖고 있는 것처럼 보이게 할 수 있습니다. 온라인 및 파일 시스템은 가상 카드 리더기와 가상 라인 프린터를 제공할 수 있습니다. 일반 사용자의 시간 공유 터미널은 가상 머신 운영자의 콘솔 역할을 합니다.

가상 머신 개념은 각 가상 머신이 다른 모든 가상 머신과 격리되어 있기 때문에 시스템 리소스를 완벽하게 보호합니다. 그러나 이러한 격리로 인해 리소스를 직접 공유할 수는 없습니다. 가상 머신 시스템은 운영 체제 연구 및 개발을 위한 완벽한 도구입니다. 시스템 개발은 물리적 머신이 아닌 가상 머신에서 이루어지므로 정상적인 시스템 운영이 중단되지 않습니다. 가상 머신 개념은 기본 머신의 정확한 복사본을 제공해야 하기 때문에 구현하기 어렵습니다. 단점은 시뮬레이션된 가상 머신 작업 수가 많기 때문에 가상 머신에 시스템 오버헤드가 증가하고, VM OS의 효율성은 VM 모니터가 시뮬레이션해야 하는 작업 수에 따라 달라진다는 점입니다. 가상 머신은 프로세스와 시스템이라는 두 가지 수준으로 나눌 수 있습니다.

VM/370은 1979년 Seawright와 MacKinnon이 소개한 가상 머신입니다. 이는 예리한 관찰을 기반으로 합니다. 즉, 시간 공유 시스템은 (1) 다중 프로그래밍과 (2) 확장 머신을 제공하며 베어 하드웨어보다 더 편리한 인터페이스를 제공합니다. VM/370의 핵심은 이 두 기능을 완전히 분리하는 것입니다.

시스템의 핵심인 가상 머신 모니터는 베어 하드웨어에서 실행되며 다중 프로그래밍을 수행하여 그림 1-28과 같이 상위 계층에 하나가 아닌 여러 개의 가상 머신을 제공합니다. 그러나 다른 모든 운영 체제와 달리 이러한 가상 머신은 파일 및 기타 멋진 기능을 갖춘 확장 머신이 아닙니다. 대신 커널/사용자 모드, I/O, 인터럽트 및 기타 실제 시스템에 있는 모든 것을 포함하여 베어 하드웨어의 정확한 복사본입니다.

가상화는 웹 호스팅 세계에서도 인기가 있습니다. 가상화가 없으면 웹 호스팅 고객은 공유 호스팅과 전용 호스팅 중에서 선택해야 합니다. 웹 호스팅 회사가 임대용으로 가상 머신을 제공하는 경우 하나의 물리적 머신이 여러 가상 머신을 실행할 수 있으며 각 가상 머신은 완전한 머신처럼 보입니다. 가상 머신을 임대한 고객은 전용 서버 비용의 극히 일부만으로 원하는 운영 체제와 소프트웨어를 실행할 수 있습니다(동일한 물리적 머신이 여러 가상 머신을 동시에 지원하기 때문).

가상화의 또 다른 용도는 즐겨 사용하는 응용 프로그램 패키지 중 일부가 하나에서 실행되고 다른 응용 프로그램 패키지가 다른 운영 체제에서 실행되기 때문에 두 개 이상의 운영 체제(예: Windows 및 Linux)를 동시에 실행할 수 있기를 원하는 최종 사용자를 위한 것입니다. 이러한 상황은 아래 그림 (a)에 설명되어 있습니다. 여기서 "하이퍼바이저"라는 용어는 현재 일반적으로 사용되는 유형 1 하이퍼바이저로 이름이 변경되었습니다. "하이퍼바이저"는 현재 사람들이 준비한 것보다 더 많은 키 입력을 요구하기 때문입니다.

(a) 유형 1 하이퍼바이저. (b) 순수 유형 2 하이퍼바이저. (c) 실용적인 유형 2 관리 프로그램.

가상 컴퓨터의 아키텍처 예입니다.

가상 머신이 사용되는 또 다른 영역은 Java 프로그램을 실행하는 것이지만 방식은 다릅니다. Sun Microsystems는 Java 프로그래밍 언어를 발명할 때 JVM(Java Virtual Machine)이라는 가상 머신(즉, 컴퓨터 아키텍처)도 발명했습니다. Java 컴파일러는 JVM용 코드를 생성한 다음 일반적으로 소프트웨어 JVM 인터프리터에 의해 실행됩니다. 이 접근 방식의 장점은 JVM 코드가 인터넷을 통해 JVM 인터프리터가 있고 실행되는 모든 컴퓨터로 전송될 수 있다는 것입니다. 예를 들어 컴파일러가 SPARC 또는 x86 바이너리를 생성하는 경우 쉽게 배포하고 실행할 수 없습니다. JVM 사용의 또 다른 장점은 인터프리터가 올바르게 구현되면(사소한 문제 아님) 수신 JVM 프로그램의 보안을 확인한 다음 보호된 환경에서 실행하여 데이터를 훔치거나 손상을 일으킬 수 없다는 것입니다.

18.1.4 운영 체제 구성 요소

게임 개발은 모듈식 아키텍처와 효율적인 개발 환경의 도움 없이는 거의 불가능한 매우 복잡한 프로세스이자 프로젝트입니다. 현재 시장에 나와 있는 다양한 제품은 게임 애플리케이션, 게임 엔진, 그래픽 API, 운영 체제, 장치 드라이버 및 하드웨어 장치 등 위에서 아래로 추상화 수준으로 구성된 구조를 공유합니다.

아래 그림은 좀 더 상세한 계층적 모듈입니다. 운영 체제(OS)는 타사 SDK와 그래픽 API와 같은 드라이버 사이에 있습니다. 이는 이전과 다음 사이의 중요한 역할과 의사소통 다리 역할을 합니다. 이는 전체 컴퓨터 계층 구조에서 매우 중요한 구성 요소입니다.

컴퓨터 시스템은 하드웨어, 운영 체제, 응용 프로그램, 사용자라는 네 부분으로 나눌 수 있습니다. 시스템 구성 요소의 추상적인 보기가 그림 1에 나와 있습니다.

  1. 하드웨어: CPU, 메모리, I/O 장치 등.

  2. 운영 체제: 정부와 마찬가지로 컴퓨터 시스템 운영에 있어 하드웨어의 올바른 사용을 제공합니다.

  3. 응용 프로그램: 컴파일러, 데이터베이스 시스템, 웹 브라우저 등 사용자의 컴퓨팅 문제를 해결합니다.

  4. 사용자: 사람, 기계 또는 기타 컴퓨터.

최상위 수준에서 컴퓨터는 프로세서, 메모리 및 I/O 구성 요소와 각 유형의 모듈이 하나 이상으로 구성됩니다. 이러한 구성 요소는 프로그램을 실행하는 컴퓨터의 주요 기능을 달성하는 방식으로 상호 연결됩니다. 네 가지 주요 구조 요소가 있습니다.

  • 프로세서: 컴퓨터의 동작을 제어하고 데이터 처리 기능을 수행합니다. 프로세서가 하나만 있는 경우 이를 중앙 처리 장치(CPU)라고 합니다.

  • 메인 메모리: 데이터와 프로그램을 저장합니다. 쉽게 잃어버릴 수 있으므로 컴퓨터를 끄면 메모리의 내용이 사라집니다. 대신, 컴퓨터 시스템이 종료되더라도 디스크 메모리의 내용은 유지됩니다. 주 메모리는 실제 메모리 또는 주 메모리라고도 합니다.

  • I/O 모듈: 컴퓨터와 외부 환경 간에 데이터를 이동합니다. 외부환경은 2차 저장장치(디스크 등), 통신장치, 단말기 등 다양한 장치로 구성된다.

  • 시스템 버스: 프로세서, 메인 메모리 및 I/O 모듈 간의 통신을 제공합니다.

운영 체제의 레벨은 아래 그림과 같습니다.

일반적인 시스템 아키텍처는 아래 그림에 표시되어 있으며 사용자 모드 및 커널 모드 구성 요소를 포함한 Windows의 전체 아키텍처를 보여줍니다.

위 이미지에 제시된 개념에 대한 간략한 설명은 다음과 같습니다.

  • 사용자 모드:

사용자 프로세스. 이미지 파일을 기반으로 하는 일반적인 프로세스로 Notepad.exe, cmd.exe, explorer.exe 등과 같은 시스템에서 실행됩니다.

  • 하위 시스템 DLL. 하위 시스템 DLL은 커널이 제공하는 기능에 대한 특정 보기인 하위 시스템 API를 구현하는 DLL(동적 링크 라이브러리)입니다. 기술적으로 말하면 Windows 8.1부터 Windows 하위 시스템이라는 하위 시스템이 하나만 있습니다. 하위 시스템 dll에는 kernel32.dll, user32.dll, gdi32.dll, advapi32.dll, 콤보.dll 등 잘 알려진 파일이 포함되어 있습니다. 주로 Windows의 공식 API를 구현합니다.

  • NTDLL.DLL. Windows 기본 API를 구현하는 시스템 전체 DLL은 코드의 가장 낮은 수준이며 여전히 사용자 모드에 있습니다. 가장 중요한 역할은 시스템 호출을 커널 모드로 변환하는 것입니다. 또한 힙 관리자, 이미지 로더 및 사용자 모드 스레드 풀의 일부 부분을 구현합니다.

  • 서비스 프로세스. 서비스 프로세스는 서비스 제어 관리자(SCM, services.exe에 구현됨)와 통신하고 수명 주기를 일부 제어할 수 있는 일반적인 Windows 프로세스입니다. SCM은 시작, 중지, 일시 중지, 재개 및 기타 메시지를 서비스에 보낼 수 있습니다.

  • 시스템 프로세스. 시스템 프로세스는 일반적으로 "그곳에" 있으며 일반적인 상황에서는 직접 통신하지 않는 프로세스를 설명하는 데 사용되는 포괄적인 용어입니다. 그럼에도 불구하고 그것들은 중요하며 그 중 일부는 시스템 기능에 매우 중요하며 일부를 종료하면 치명적인 시스템 충돌이 발생할 수 있습니다. 일부 시스템 프로세스는 기본 프로세스입니다. 즉, 기본 API(NTDLL로 구현된 API)만 사용합니다. 시스템 프로세스의 예로는 Smss.exe, Lsass.exe, Winlogon.exe, Services.exe 등이 있습니다.

  • 하위 시스템 프로세스. Csrss.exe 이미지를 실행하는 Windows 하위 시스템 프로세스는 커널 도우미로 간주될 수 있으며 Windows 시스템에서 실행되는 프로세스를 관리하는 데 사용됩니다. 이는 중요한 프로세스이며 종료되면 시스템이 충돌합니다. 일반적으로 CSRS.exe 인스턴스는 하나이므로 표준 시스템에는 두 개의 인스턴스가 있습니다. 하나는 세션용(보통 0)이고 다른 하나는 로그인한 사용자 세션용(보통 1)입니다. CSRS.exe는 Windows 하위 시스템의 "관리자"(현재 남아 있는 유일한 관리자)이지만 그 중요성은 이 역할 이상으로 확장됩니다.

  • 커널 모드:

임원 계층(Executive). 실행 계층은 NtOskrnl.exe("커널")의 상위 계층이며 커널 모드에서 대부분의 코드를 호스팅합니다. 여기에는 기본 커널 계층보다 훨씬 큰 객체 관리자, 메모리 관리자, I/O 관리자, 플러그 앤 플레이 관리자, 전원 관리자, 구성 관리자 등 다양한 관리자가 주로 포함됩니다.

  • 커널. 커널 계층은 스레드 스케줄링, 인터럽트 및 예외 스케줄링, 뮤텍스 및 세마포어와 같은 다양한 커널 프리미티브 구현을 포함하여 커널 모드 운영 체제 코드의 가장 기본적이고 시간에 민감한 부분을 구현합니다. 일부 커널 코드는 효율성을 높이고 CPU 관련 세부 정보에 직접 액세스할 수 있도록 CPU 관련 기계어로 작성되었습니다.

장치 드라이버. 장치 드라이버는 코드가 커널 모드에서 실행되므로 커널의 전체 기능을 갖는 로드 가능한 커널 모듈입니다. 클래식 장치 드라이버는 하드웨어 장치와 나머지 운영 체제 사이를 연결하는 역할을 하며, 다른 유형의 드라이버는 필터링 기능을 제공합니다.

  • Win32k.sys. Windows 하위 시스템의 커널 모드 구성 요소는 기본적으로 Windows의 사용자 인터페이스 부분과 기존 GDI(그래픽 장치 인터페이스) API를 처리하는 커널 모듈(드라이버)입니다. 즉, 모든 창 작업은 이 구성 요소에 의해 처리되며 나머지 시스템은 UI에 대해 전혀 알지 못합니다.

하드웨어 추상화 계층(HAL). HAL은 CPU에 가장 가까운 하드웨어의 추상화 계층으로, 장치 드라이버가 인터럽트 컨트롤러나 DMA 컨트롤러에 대한 상세하고 구체적인 지식이 필요하지 않은 API를 사용할 수 있도록 해줍니다. 물론 이 계층은 하드웨어 장치를 처리하기 위해 작성된 장치 드라이버에 유용합니다. 목표는 "핵심" 운영 체제에서 하드웨어별 루틴을 분리하고 이식성을 제공하며 가독성을 높이는 것입니다.

  • Hyper-V 하이퍼바이저. Hyper-V 하이퍼바이저는 VBS(가상화 기반 보안)가 지원되는 경우 Windows 10 및 Server 2016(이상) 시스템에 존재합니다. VBS는 실제 머신이 실제로 Hyper-V에 의해 제어되는 가상 머신인 추가 보안 계층을 제공합니다.

다음 그림은 마이크로커널 구조의 개략도입니다.

OS 구성요소에는 일반적으로 제어 프로그램, 시스템 서비스 프로그램, 도구 프로그램 등이 포함된다. 그 중 제어 프로그램은 다른 프로그램을 실행할 수 있는 환경을 조성하고, 윈도우 환경의 GUI와 같은 그래픽 인터페이스를 통해 컴퓨터 동작을 제어 및 유지한다. 시스템 서비스 프로그램은 사용자 개입 없이 특정 기능을 수행할 수 있습니다. 수동으로 시작하거나 운영 체제가 시작될 때 자동으로 시작되도록 구성할 수 있습니다. 운영 체제가 실행될 때 백그라운드에서 실행되며 작업 스케줄러, Windows 업데이트, 메신저 서비스, 플러그 앤 플레이, 인덱싱 서비스 등과 같은 시스템 성능, 응답성, 에너지 효율성 및 보안에 영향을 줄 수 있습니다. 유틸리티 프로그램은 시스템 리소스 관리와 관련된 매우 구체적인 작업을 수행하고 다양한 컴퓨터 구성 요소가 작동하는 방식에 중점을 둡니다. 일반적으로 사용되는 유틸리티는 디스크 포맷 유틸리티, 바이러스 백신 유틸리티, 백업 유틸리티, 파일 관리자, 디스크 정리 등입니다.

운영 체제는 필요한 기능을 수행하는 커널로 제어권을 전송하기 위해 링 전환과 관련된 메커니즘을 통해 프로세스에서 액세스하는 서비스를 제공합니다. 이는 각 서비스 호출에 프로세서 상태가 저장되고 보호 도메인 전송이 수행되는 컨텍스트 전환의 오버헤드가 포함된다는 중요한 단점이 있습니다. 그러나 고성능 커널 없는 운영 체제 아키텍처에서 발견된 바와 같이 분할을 지원하는 프로세서 아키텍처에서는 링 변환을 수행하지 않고 운영 체제에서 제공하는 서비스에 액세스할 때 상당한 성능 향상을 얻을 수 있습니다. KLOS는 이러한 설계를 기반으로 구축된 커널 없는 운영 체제입니다. 해당 서비스 호출 메커니즘은 현재 널리 구현된 서비스 또는 시스템 호출 메커니즘보다 훨씬 빠르고, 기존 트랩/인터럽트보다 4배 빠르며, Intel SYSENTER/SYSEXIT 빠른 시스템 호출 모델보다 2배 빠릅니다.

KLOS의 아키텍처 다이어그램.

18.1.5 고성능 운영체제 개발

일반적인 OS 고성능 개발 기술은 다음과 같습니다.

  • 온라인 및 오프라인 운영. 각 I/O 장치에는 장치 컨트롤러라는 특수 서브루틴이 작성되며 일부 I/O 장치에는 온라인 작업(프로세서에 연결됨) 또는 오프라인 작업(제어 장치에 의해 실행됨)이 장착되어 있습니다.

  • 버퍼. 버퍼는 I/O 전송 중에 데이터를 보관하는 데 사용되는 주 메모리 영역입니다. 입력 시 데이터는 I/O 채널을 통해 버퍼에 저장되고, 전송이 완료되면 프로세서에서 해당 데이터에 액세스할 수 있습니다. 단일 또는 이중 버퍼링될 수 있습니다.

  • 온라인(주변 장치의 동시 온라인 작동). 온라인은 디스크를 매우 큰 버퍼로 사용하는데, 이는 장치가 서로 다른 속도로 데이터에 액세스하기 때문에 유용합니다. 버퍼는 느린 장치가 따라잡는 동안 데이터를 준비할 수 있는 대기 스테이션을 제공합니다. 온라인에서는 한 작업의 계산과 다른 작업의 I/O가 겹칠 수 있습니다.

  • 다중 프로그래밍. 다중 프로그래밍에서는 여러 프로그램이 주 메모리에 동시에 보관되고 CPU가 이들 사이를 전환하므로 CPU에는 항상 실행할 프로그램이 있습니다. 운영 체제는 메모리에서 프로그램 실행을 시작하고 해당 프로그램이 I/O 작업 등을 기다려야 하는 경우 다른 프로그램으로 전환합니다. 다중 프로그래밍은 CPU 활용도를 향상시킵니다. 다중 프로그래밍 시스템은 다양한 시스템 자원이 효율적으로 활용되는 환경을 제공하지만, 컴퓨터 시스템과 사용자 상호 작용을 제공하지는 않습니다. 장점은 CPU 활용도가 높다는 점인데, 많은 프로그램이 거의 동시에 CPU를 할당하는 것 같습니다. 단점은 CPU 스케줄링, 메모리에 많은 작업 수용, 메모리 관리가 필요하다는 것입니다.

  • 병렬 시스템. 시스템에는 2개 이상의 프로세서가 있으며 이러한 프로세서는 컴퓨터 버스, 시계, 메모리 및 I/O 장치를 공유합니다. 장점은 처리량(시간 단위로 완료된 프로그램 수)이 증가한다는 것입니다.

  • 분산 시스템. 여러 물리적 프로세서 간에 계산을 분산하려면 통신 링크를 통해 2개 이상의 독립적인 컴퓨터 시스템을 연결해야 합니다. 따라서 각 프로세서는 고유한 OS와 로컬 메모리를 갖고 있으며, 프로세서들은 다양한 통신선(고속버스나 전화선 등)을 통해 서로 통신한다.

분산 시스템의 장점:

리소스 공유, 파일, 프린터 공유가 가능합니다.

  • 계산 속도가 향상되었습니다. 각 프로세서가 작업의 일부를 동시에 수행할 수 있도록 작업을 분할할 수 있습니다(부하 공유).

  • 신뢰성. 하나의 프로세서에 오류가 발생하더라도 나머지 프로세서는 여전히 정상적으로 작동할 수 있습니다.

  • 의사소통. 이메일, FTP 등.

  • 개인용 컴퓨터. 단일 사용자 전용 컴퓨터 시스템인 PC 운영 체제는 다중 사용자도 멀티태스킹 시스템도 아닙니다. PC 운영체제의 목표는 CPU와 I/O 활용률을 극대화하는 것이 아니라 사용자 편의성과 응답성을 극대화하는 것입니다. 마이크로소프트 윈도우즈나 애플 매킨토시 같은 것이죠.

운영 체제 커널 기능의 캐싱 모델은 기존 하드웨어 캐시 메모리 데이터와 마찬가지로 커널 캐시 스레드 및 주소 공간과 같은 운영 체제 개체를 캐시할 수 있는 운영 체제 기능의 캐싱 모델을 설명합니다. 사용자 모드 애플리케이션 커널은 이러한 개체의 로드 및 쓰기를 처리하고 애플리케이션별 관리 정책 및 메커니즘을 구현합니다. 다중 프로세서에서 캐시 커널을 구현하고 해당 성능을 측정한 경험에 따르면 이 캐시 모델은 기존 모놀리식 운영 체제에 비해 경쟁력 있는 성능을 제공하는 동시에 시스템 리소스에 대한 애플리케이션 수준 제어, 향상된 모듈성, 향상된 확장성, 더 작은 크기 및 오류 제어 기반을 제공할 수 있습니다.

아래 그림에 표시된 것처럼 다양한 애플리케이션, 서버 커널 및 운영 체제 에뮬레이터가 동일한 하드웨어에서 동시에 실행될 수 있습니다. 각 캐시 커널/MPM에 의해 복제되는 SRM(System Resource Manager)이라는 특수 응용 프로그램 커널은 다른 응용 프로그램 커널 간의 리소스 공유를 관리하여 부당한 간섭 없이 동시에 동일한 하드웨어를 공유할 수 있도록 합니다. 예를 들어, 대규모 시뮬레이션을 실행하는 악성 애플리케이션이 동일한 ParaDiGM 구성에서 실행되는 시분할 서비스를 제공하는 UNIX 에뮬레이터의 실행을 커널로 방해하는 것을 방지할 수 있습니다.

소프트웨어 아키텍처 개요.

다음 그림은 캐시 커널 개체 간의 종속성을 보여줍니다. 다이어그램의 화살표는 화살표 꼬리에 있는 개체에서 머리에 있는 개체에 대한 참조를 나타내므로 캐시 종속성입니다. 예를 들어, 물리적 메모리 맵의 신호 맵은 자신이 속한 커널 개체를 참조하는 주소 공간을 참조하는 스레드를 참조합니다. 따라서 스레드, 주소 공간 또는 커널이 언로드되면 신호 맵도 언로드되어야 합니다.

캐시 데이터 아키텍처.

18.2 컴퓨터 하드웨어 개요

운영 체제는 이를 실행하는 컴퓨터의 하드웨어와 밀접하게 연결되어 컴퓨터의 명령어 세트를 확장하고 리소스를 관리합니다. 간단한 개인용 컴퓨터는 아래 그림과 유사한 모델로 추상화될 수 있습니다. CPU, 메모리, I/O 장치는 모두 시스템 버스를 통해 연결되고 이를 통해 서로 통신합니다. 최신 개인용 컴퓨터는 여러 버스를 포함하는 더욱 복잡한 아키텍처를 가지고 있습니다.

컴퓨터 하드웨어 아키텍처의 추상 다이어그램.

컴퓨터 하드웨어 구성도.

Intel Core i7 구조 다이어그램.

18.2.1 CPU

컴퓨터의 "두뇌"는 메모리에서 명령을 가져와 실행하는 CPU입니다. 모든 CPU의 기본 주기는 메모리에서 첫 번째 명령어를 가져오고, 이를 디코딩하여 유형과 피연산자를 결정하고, 실행한 다음 후속 명령어를 가져오고, 디코딩하고, 실행하는 것입니다. 루프는 프로그램이 끝날 때까지 반복됩니다. 이런 식으로 프로그램을 실행해 보세요.

각 CPU에는 실행할 수 있는 특정 명령 세트가 있습니다. 따라서 x86 프로세서는 ARM 프로그램을 실행할 수 없고 ARM 프로세서는 x86 프로그램을 실행할 수 없습니다. 명령어나 데이터 워드를 가져오기 위해 메모리에 액세스하는 것은 명령어를 실행하는 것보다 시간이 오래 걸리기 때문에 모든 CPU에는 중요한 변수와 임시 결과를 보관하는 레지스터가 포함되어 있습니다. 따라서 명령어 세트에는 일반적으로 메모리의 단어를 레지스터로 로드하고 레지스터의 단어를 메모리에 저장하는 명령어가 포함됩니다. 두 단어를 추가하고 그 결과를 레지스터나 메모리에 저장하는 것과 같은 다른 명령어는 레지스터, 메모리 또는 둘 다의 두 피연산자를 결과로 결합합니다.

변수와 임시 결과를 보관하기 위한 범용 레지스터 외에도 대부분의 컴퓨터에는 프로그래머가 볼 수 있는 여러 특수 목적 레지스터가 있습니다. 그 중 하나는 가져올 다음 명령어의 메모리 주소를 포함하는 프로그램 카운터입니다. 명령어를 가져온 후 프로그램 카운터는 후속 명령어를 가리키도록 업데이트됩니다. 또 다른 레지스터는 메모리에 있는 현재 스택의 맨 위를 가리키는 스택 포인터입니다. 스택에는 입력되었지만 아직 종료되지 않은 각 프로세스에 대해 하나의 프레임이 포함됩니다. 프로시저의 스택 프레임에는 레지스터에 저장되지 않은 입력 매개변수, 지역 변수 및 임시 변수가 포함됩니다. 또 다른 레지스터는 PSW(Program Status Word)입니다. 이 레지스터에는 비교 명령어, CPU 우선순위, 모드(사용자 또는 커널) 및 기타 다양한 제어 비트에 의해 설정된 조건 코드 비트가 포함되어 있습니다. 사용자 프로그램은 일반적으로 전체 PSW를 읽을 수 있지만 일반적으로 해당 필드 중 일부만 쓸 수 있습니다. PSW는 시스템 호출과 I/O에서 중요한 역할을 합니다.

운영 체제는 모든 레지스터에 대해 완전한 지식을 가지고 있어야 합니다. CPU를 멀티플렉싱할 때 운영 체제는 일반적으로 실행 중인 프로그램을 중지하여 다른 프로그램을 (다시) 시작합니다. 실행 중인 프로그램이 중지될 때마다 운영 체제는 나중에 프로그램이 실행될 때 복원할 수 있도록 모든 레지스터를 저장해야 합니다.

성능을 향상시키기 위해 CPU 설계자들은 한 번에 하나씩 명령을 가져오고, 디코딩하고, 실행하는 단순한 모델을 오랫동안 포기해 왔습니다. 많은 최신 CPU에는 여러 명령을 동시에 실행할 수 있는 기능이 있습니다. 예를 들어, CPU에는 별도의 페치, 디코드 및 실행 단위가 있을 수 있으므로 명령어 n을 실행할 때 디코드 명령어 n+1 및 페치 명령어 n+2일 수도 있습니다. 이 조직을 3차 덕트의 경우 아래 그림 (a)와 같이 덕트라고 부릅니다. 더 긴 파이프가 일반적입니다. 대부분의 파이프라인 설계에서는 이전 명령이 조건부 실행 분기였더라도 명령을 파이프라인으로 가져오면 해당 명령을 실행해야 합니다. 파이프라인은 기본 시스템의 복잡성을 노출하고 이를 처리해야 하기 때문에 컴파일러 작성자와 운영 체제 작성자에게 큰 문제를 야기합니다.

위의 (b)에 표시된 것처럼 파이프라인 설계보다 더 발전된 것은 수퍼스칼라 CPU입니다. 이 디자인에는 정수 연산용, 부동 소수점 연산용, 부울 연산용 등 여러 실행 단위가 있습니다. 두 개 이상의 명령어가 실행될 수 있을 때까지 동시에 가져오고, 디코딩되고, 보유 버퍼에 덤프됩니다. 실행 단위를 사용할 수 있게 되면 보유 버퍼를 조사하여 처리할 수 있는 명령이 있는지 확인하고, 처리할 수 있는 명령이 있으면 버퍼에서 해당 명령을 제거하고 실행합니다. 이 설계의 한 가지 의미는 프로그램 명령이 종종 순서 없이 실행된다는 것입니다. 대부분의 경우 생성된 결과가 순차 구현에 의해 생성된 결과와 동일하도록 보장하는 것은 하드웨어에 달려 있지만, 앞으로 살펴보겠지만 이는 운영 체제에 성가신 복잡성을 초래합니다.

앞에서 언급했듯이 임베디드 시스템에서 사용되는 매우 간단한 CPU를 제외한 대부분의 CPU에는 커널 모드와 사용자 모드의 두 가지 모드가 있습니다. 일반적으로 PSW의 비트가 모드를 제어합니다. 커널 모드에서 실행될 때 CPU는 명령어 세트의 모든 명령어를 실행하고 하드웨어의 모든 기능을 사용할 수 있습니다. 데스크탑과 서버에서 운영 체제는 일반적으로 커널 모드에서 실행되어 전체 하드웨어에 대한 액세스를 제공합니다. 대부분의 임베디드 시스템에서 일부는 커널 모드에서 실행되고 나머지 운영 체제는 사용자 모드에서 실행됩니다.

사용자 프로그램은 항상 사용자 모드에서 실행됩니다. 이 모드에서는 명령의 하위 집합만 실행되고 기능의 하위 집합에 액세스할 수 있습니다. 일반적으로 I/O 및 메모리 보호와 관련된 모든 명령은 사용자 모드에서 허용되지 않습니다. 물론 PSW 모드 비트를 설정하여 커널 모드로 들어가는 것도 금지됩니다.

운영 체제에서 서비스를 얻으려면 사용자 프로그램은 커널에 들어가 운영 체제를 호출하는 시스템 호출을 수행해야 합니다. TRAP 명령어는 사용자 모드에서 커널 모드로 전환하고 운영 체제를 시작합니다. 작업이 완료된 후 시스템 호출 이후의 지시에 따라 제어권이 사용자 프로그램으로 반환됩니다. 사용자 모드에서 커널 모드로 전환하는 추가 속성이 있는 특수 프로세스 호출로 생각하십시오.

시스템 호출을 수행하는 명령 외에도 컴퓨터에는 트랩이 있다는 점은 주목할 가치가 있습니다. 대부분의 다른 트랩은 하드웨어 경고 예외(예: 0으로 나누기 시도 또는 부동 소수점 언더플로)로 인해 발생합니다. 모든 경우에 운영 체제는 제어권을 갖고 무엇을 할지 결정해야 합니다. 프로그램이 오류로 종료되어야 하는 경우도 있고, 오류를 무시할 수도 있는 경우도 있습니다(언더플로우 수를 0으로 설정할 수 있음). 마지막으로, 프로그램이 특정 유형의 조건을 처리하겠다고 미리 선언하면 프로그램에 제어권을 다시 넘겨 문제를 처리하도록 할 수 있습니다.

멀티스레딩 외에도 많은 CPU 칩에는 이제 4개, 8개 이상의 완전한 프로세서 또는 코어가 있습니다. 아래 그림의 멀티 코어 칩은 각각 자체 독립 CPU를 갖춘 4개의 작은 칩을 효과적으로 운반하며 Intel Xeon Phi 및 Tilera TilePro와 같은 일부 프로세서는 이미 단일 칩에서 60개 이상의 코어를 실행합니다. 이러한 멀티 코어 칩을 사용하려면 반드시 멀티 프로세서 운영 체제가 필요합니다.

그건 그렇고, 숫자로만 보면 수천 개의 마이크로 코어가 있는 프로세서인 최신 GPU(그래픽 처리 장치)와 비교할 수 있는 것은 없으며 그래픽 응용 프로그램에서 다각형을 렌더링하는 것과 같이 병렬로 수행되는 많은 작은 계산에 매우 유용합니다. 순차적인 작업을 잘 수행하지 못하고 프로그래밍하기가 어렵습니다. GPU는 운영 체제(예: 네트워크 트래픽 암호화 또는 처리)에 유용하지만 운영 체제 자체가 GPU에서 실행될 가능성은 거의 없습니다.

(a) 공유 L2 캐시가 있는 쿼드 코어 칩. (b) 독립 L2 캐시를 갖춘 쿼드 코어 칩.

18.2.2 메모리

모든 컴퓨터의 두 번째 주요 구성 요소는 메모리입니다. 이상적으로 메모리는 매우 빠르고(CPU가 메모리에 묶여 있지 않도록 명령을 실행하는 것보다 빠르며) 매우 크고 저렴해야 합니다. 현재 기술로는 이러한 목표를 모두 충족할 수 없으므로 다양한 접근 방식이 취해집니다. 메모리 시스템은 아래 그림과 같이 계층 구조로 구성되어 있습니다. 최상위 계층은 하위 계층보다 더 빠른 속도, 더 작은 용량, 더 높은 비트당 비용을 가지며, 종종 10억 배 이상 더 높습니다.

메모리 계층 구조 및 속도 다이어그램.

최상위 레이어는 CPU 내부의 레지스터로 구성되며, CPU와 동일한 재질로 만들어지므로 CPU만큼 빠르므로 액세스에 지연이 없습니다. 사용 가능한 저장 용량은 일반적으로 32비트 CPU에서 32 × 32비트이고 64비트 CPU에서 64 × 64비트이며 두 경우 모두 1KB 미만입니다. 프로그램은 소프트웨어에서 레지스터 자체를 관리해야 합니다(즉, 레지스터에 무엇을 저장할지 결정).

캐싱은 주로 하드웨어에 의해 제어됩니다. 주 메모리는 일반적으로 64바이트의 캐시 라인으로 나누어지며, 캐시 라인 0의 주소는 0~63이고, 캐시 라인 1의 주소는 64~127입니다. 가장 일반적으로 사용되는 캐시 라인은 CPU 내부 또는 CPU와 매우 가까운 캐시에 보관되며, 프로그램이 메모리 한 단어를 읽어야 할 때 캐시 하드웨어는 필요한 라인이 캐시에 있는지 확인합니다. 캐시 적중이라고 하는 경우 캐시에서 요청이 충족되고 메모리 요청이 버스를 통해 주 메모리로 전송되지 않습니다. 캐시 적중은 일반적으로 약 2개의 클록 주기가 소요되며, 캐시 미스는 메모리로 전송되어야 하므로 상당한 시간 손실이 발생합니다. 캐시 비용이 높기 때문에 크기가 제한되어 있으며 일부 시스템에는 2개 또는 3개 수준의 캐시가 있으며 각 캐시 수준은 이전 수준보다 느리고 큽니다.

캐시 및 메모리 구조.

캐싱은 RAM 라인 캐싱뿐만 아니라 컴퓨터 과학의 여러 영역에서 중요한 역할을 합니다. 리소스를 여러 부분으로 나눌 수 있고 그 중 일부가 다른 부분보다 훨씬 더 많이 사용되는 경우 성능 향상을 위해 캐싱이 사용되는 경우가 많습니다. 운영 체제는 항상 이를 사용합니다. 예를 들어, 대부분의 운영 체제는 자주 사용하는 파일(세그먼트)을 디스크에서 반복적으로 검색하는 것을 방지하기 위해 주 메모리에 보관합니다. 마찬가지로 긴 경로 이름을 변환한 결과는 다음과 같습니다.

cpp
/home/ast/projects/minix3/src/kernel/clock.c

반복 검색을 피하기 위해 파일이 있는 디스크 주소에 캐시할 수 있습니다. 마지막으로 웹페이지 주소(URL)가 네트워크 주소(IP 주소)로 변환되면 나중에 사용할 수 있도록 결과를 캐시할 수 있습니다. 다른 용도도 많이 있습니다. 모든 캐싱 시스템에서는 다음과 같은 몇 가지 문제가 빠르게 발생합니다.

  1. 새 항목을 캐시에 넣을 시기.

  2. 새 항목을 어느 캐시 라인에 넣을지.

  3. 슬롯이 필요할 때 캐시에서 제거할 항목입니다.

  4. 새로 검색된 항목을 더 큰 메모리에 넣을 위치입니다.

모든 문제가 모든 캐싱 상황과 관련된 것은 아닙니다. CPU 캐시의 주 메모리 캐시 라인의 경우 일반적으로 캐시가 누락될 때마다 새 항목이 입력됩니다. 사용할 캐시 라인은 일반적으로 참조된 메모리 주소의 상위 비트 중 일부를 사용하여 계산됩니다. 예를 들어, 64바이트 및 32비트 주소가 있는 4096 캐시 라인의 경우 비트 6~17을 사용하여 캐시 라인을 지정할 수 있습니다. 여기서 비트 0~5는 캐시 라인 내의 바이트입니다. 이 경우 삭제되는 항목은 새 데이터가 들어온 항목과 동일하지만 다른 시스템에서는 그렇지 않을 수도 있습니다. 마지막으로, 캐시 라인이 주 메모리에 다시 쓰여질 때(캐시 라인이 캐시된 이후 수정된 경우), 다시 쓰여질 메모리의 위치는 문제의 주소에 의해 고유하게 결정됩니다.

캐싱은 좋은 생각입니다. 최신 CPU에는 두 개의 캐시가 있습니다. 첫 번째 수준 캐시 또는 L1 캐시는 항상 CPU 내부에 있으며 일반적으로 디코딩된 명령을 CPU의 실행 엔진에 공급합니다. 대부분의 칩에는 많이 사용되는 데이터 단어를 저장하는 두 번째 L1 캐시가 있으며 L1 캐시는 일반적으로 각각 16KB입니다. 또한 일반적으로 L2 캐시라고 하는 두 번째 캐시가 있는데, 이는 최근에 사용된 메모리 단어 수 메가바이트를 보관합니다. 1단계 캐시와 2단계 캐시의 차이점은 타이밍이다. 첫 번째 수준 캐시에 대한 액세스에는 지연이 없지만 두 번째 수준 캐시에 대한 액세스는 1~2 클록 주기만큼 지연됩니다.

멀티코어 칩에서 설계자는 캐시가 어디에 위치해야 하는지 결정해야 합니다. 아래 그림 (a)에서 모든 코어는 단일 L2 캐시를 공유합니다. 이 방법은 Intel 멀티 코어 칩에서 사용됩니다. 반면, 아래 (b)에서는 각 코어에 자체 L2 캐시가 있는데, 이는 AMD가 취한 접근 방식입니다. 각 전략에는 장단점이 있습니다. 예를 들어 Intel의 공유 L2 캐시에는 더 복잡한 캐시 컨트롤러가 필요하지만 AMD 접근 방식은 L2 캐시 일관성을 유지하기가 더 어렵습니다.

위에 표시된 계층 구조에서 메인 메모리는 바로 뒤에 있으며 종종 RAM(Random Access Memory)이라고 불리고 이전에는 코어 메모리라고 불렸던 메모리 시스템의 주력 장치입니다. 1950년대와 1960년대 컴퓨터는 자화 가능한 작은 페라이트 코어를 메인 메모리로 사용했고 캐시에서 충족할 수 없는 모든 CPU 요청은 메인 메모리로 갔기 때문입니다.

많은 컴퓨터에는 주 메모리 외에도 소량의 비휘발성 랜덤 액세스 메모리가 있습니다. RAM과 달리 비휘발성 메모리는 전원이 꺼져도 내용이 손실되지 않습니다. ROM(읽기 전용 메모리)은 공장에서 프로그래밍되어 있으며 나중에 변경할 수 없습니다. 빠르고 저렴하며 일부 컴퓨터에서는 컴퓨터를 시작하는 데 사용되는 부트로더가 ROM에 포함되어 있습니다. 또한 일부 I/O 카드에는 하위 수준 장치 제어를 처리하는 ROM이 함께 제공됩니다.

EEPROM(Electrically Erasable PROM) 및 플래시 메모리도 비휘발성이지만 ROM과 달리 지우고 다시 쓸 수 있습니다. 그러나 이를 기록하는 데는 RAM에 기록하는 것보다 훨씬 더 많은 시간이 걸리기 때문에 ROM과 동일한 방식으로 사용되며 프로그램의 오류를 현장에서 다시 작성하여 수정할 수 있다는 추가 기능만 있습니다.

플래시 메모리는 휴대용 전자 장치의 저장 매체, 디지털 카메라의 필름, 휴대용 음악 플레이어의 디스크 등 두 가지 용도로 흔히 사용됩니다. 플래시 메모리의 속도는 RAM과 디스크의 중간 수준입니다. 또한 디스크 메모리와 달리 너무 많이 지우면 마모됩니다.

또 다른 유형의 메모리는 휘발성인 CMOS입니다. 많은 컴퓨터는 현재 시간과 날짜를 저장하기 위해 CMOS 메모리를 사용합니다. 시간을 추가하는 CMOS 메모리와 시계 회로는 소형 배터리로 구동되므로 컴퓨터의 플러그를 뽑아도 시간이 올바르게 업데이트됩니다. CMOS 메모리는 부팅할 디스크와 같은 구성 매개변수도 저장할 수 있습니다. CMOS는 전력 소모가 매우 적어서 공장에서 설치된 배터리가 일반적으로 몇 년 동안 지속되기 때문에 사용됩니다. 그러나 컴퓨터가 오작동하기 시작하면 컴퓨터는 알츠하이머병에 걸릴 수 있으며 부팅할 하드 드라이브와 같은 수년 동안 알고 있던 사항을 잊어버릴 수 있습니다.

18.2.3 디스크

디스크 스토리지는 RAM보다 비트당 100배 저렴하며 일반적으로 200배 더 큽니다. 유일한 문제는 데이터에 대한 무작위 액세스가 거의 3배 정도 느리다는 것입니다. 그 이유는 아래 그림과 같이 디스크가 기계적인 장치이기 때문이다.

자기 디스크는 5400, 7200, 10800 RPM 이상으로 회전하는 하나 이상의 금속 디스크로 구성됩니다. 비닐 레코드를 재생하는 오래된 33RPM 축음기의 픽업 암과 유사하게 로봇 팔이 모서리에서 플래터 주위를 회전하며 정보가 일련의 동심원으로 디스크에 기록됩니다. 주어진 팔 위치에서 각 머리는 트랙이라는 원형 영역을 읽을 수 있으며, 주어진 팔 위치에 대한 모든 트랙은 함께 원통을 형성합니다.

각 트랙은 일반적으로 각 512바이트의 섹터로 구분됩니다. 최신 디스크에서는 외부 실린더가 내부 실린더보다 더 많은 섹터를 포함합니다. 팔을 한 실린더에서 다른 실린더로 이동하는 데 약 1밀리초가 걸립니다. 드라이브에 따라 일반적으로 임의의 실린더로 이동하는 데 5~10밀리초가 걸립니다. 암이 올바른 트랙에 있으면 드라이브는 필요한 섹터가 헤드 아래에서 회전할 때까지 기다려야 하며 드라이브의 RPM에 따라 5ms~10ms의 추가 지연이 발생합니다. 섹터가 헤드 아래에 있으면 저가형 디스크의 읽기 또는 쓰기 속도는 50MB/초이고 고급 디스크의 읽기 및 쓰기 속도는 160MB/초입니다.

때때로 사람들이 SSD(Solid State Disk)와 같이 실제로 디스크가 아닌 디스크에 대해 이야기하는 것을 듣습니다. SSD에는 움직이는 부품이 없고, 디스크 모양의 플래터가 없으며, (플래시) 메모리에 데이터를 저장합니다. 디스크와 유사한 유일한 점은 정전 시에도 손실되지 않는 대용량 데이터를 저장한다는 것입니다.

많은 컴퓨터는 가상 메모리라는 체계를 지원합니다. 가상 메모리는 프로그램을 디스크에 배치하고 주 메모리를 가장 자주 실행되는 부분에 대한 캐시로 사용하여 실제 메모리보다 큰 프로그램을 실행할 수 있게 해줍니다. 이 체계에서는 프로그램 생성 주소를 단어가 있는 RAM의 물리적 주소로 변환하기 위해 메모리 주소를 동적으로 다시 매핑해야 합니다. 이 매핑은 CPU의 일부인 MMU(Memory Management Unit)에 의해 수행됩니다.

캐시와 MMU의 존재는 성능에 상당한 영향을 미칠 수 있습니다. 다중 프로그래밍 시스템에서 한 프로그램에서 다른 프로그램으로 전환할 때(컨텍스트 전환이라고도 함) 캐시에서 수정된 모든 블록을 지우고 MMU의 매핑 레지스터를 변경해야 할 수도 있습니다. 이 두 작업 모두 비용이 많이 들기 때문에 개발자는 주의하고 피해야 합니다.

18.2.4 I/O 장치

IO 저장 장치는 속도와 미디어를 기반으로 다음과 같은 계층적 관계를 갖습니다.

Linux의 네트워크 계층화는 다음과 같습니다.

운영체제가 관리해야 하는 자원은 CPU와 메모리만이 아닙니다. I/O 장치는 또한 운영 체제와 광범위하게 상호 작용하며 일반적으로 컨트롤러와 장치 자체라는 두 부분으로 구성됩니다.

컨트롤러는 장치를 물리적으로 제어하고 장치에서 데이터를 읽는 것과 같은 운영 체제의 명령을 받아들이고 이러한 명령을 실행하는 칩 또는 칩 그룹입니다. 많은 경우 장치의 실제 제어는 복잡하고 세부적이므로 컨트롤러의 임무는 운영 체제에 더 간단한(그러나 여전히 매우 복잡한) 인터페이스를 제공하는 것입니다. 예를 들어, 디스크 컨트롤러는 디스크 2에서 섹터 11206을 읽으라는 명령을 받아들일 수 있습니다. 그런 다음 컨트롤러는 이 선형 섹터 번호를 실린더, 섹터 및 헤드로 변환해야 합니다. 이 변환은 외부 실린더가 내부 실린더보다 더 많은 섹터를 갖고 있고 일부 불량 섹터가 다른 섹터로 다시 매핑되었다는 사실로 인해 복잡해질 수 있습니다. 그런 다음 컨트롤러는 디스크 암이 어느 실린더에 있는지 확인하고 필요한 수의 실린더를 들어가거나 빼라는 명령을 내려야 합니다. 올바른 섹터가 헤드 아래에서 회전할 때까지 기다려야 합니다. 그런 다음 드라이브에서 나오는 비트를 읽고 저장하기 시작하여 프리앰블을 제거하고 체크섬을 계산합니다. 마지막으로 입력 비트를 단어로 결합하여 메모리에 저장해야 합니다. 이 모든 작업을 수행하기 위해 컨트롤러에는 해당 작업을 수행하도록 프로그래밍된 소형 내장 컴퓨터가 포함되는 경우가 많습니다.

다른 부분은 실제 장치 자체입니다. 장치는 많은 일을 할 수 없고 표준으로 만들 수 없기 때문에 매우 간단한 인터페이스를 가지고 있습니다. 예를 들어, 모든 SATA 디스크 컨트롤러가 모든 SATA 디스크를 처리할 수 있으려면 후자가 필요합니다. SATA는 Serial ATA, ATA는 AT Attachment를 의미하며 당시 매우 강력한 6MHz 80286 프로세서를 기반으로 구축되었습니다. SATA는 현재 많은 컴퓨터에서 표준 디스크 유형입니다. 실제 장치 인터페이스는 컨트롤러 뒤에 숨겨져 있으므로 운영 체제가 보는 모든 것은 컨트롤러의 인터페이스뿐이며 이는 장치의 인터페이스와 크게 다를 수 있습니다.

각 컨트롤러 유형이 다르기 때문에 각 컨트롤러를 제어하려면 서로 다른 소프트웨어가 필요합니다. 컨트롤러와 통신하고, 명령을 내리고, 응답을 받는 소프트웨어를 장치 드라이버라고 합니다. 각 컨트롤러 제조업체는 지원하는 각 운영 체제에 대한 드라이버를 제공해야 합니다. 예를 들어 스캐너에는 OS X, Windows 7, Windows 8 및 Linux용 드라이버가 함께 제공될 수 있습니다.

드라이버를 사용하려면 커널 모드에서 실행할 수 있도록 운영 체제에 설치해야 합니다. 드라이버는 실제로 커널 외부에서 실행될 수 있으며 이제 Linux 및 Windows와 같은 운영 체제가 일부 지원을 제공하므로 대부분의 드라이버는 여전히 커널 경계 아래에서 실행됩니다. 현재 아주 소수의 시스템(예: MINIX 3)만이 사용자 공간에서 모든 드라이버를 실행합니다. 사용자 공간의 운전자는 통제된 방식으로 장치에 접근할 수 있어야 하지만 쉽지 않습니다.

드라이버를 커널에 넣는 방법에는 세 가지가 있습니다. 첫 번째 방법은 커널을 새 드라이버와 다시 연결한 다음 많은 이전 UNIX 시스템과 같은 시스템을 재부팅하는 것입니다. 두 번째 방법은 운영 체제 파일에 드라이버가 필요하다는 항목을 만든 다음 시스템을 재부팅하는 것입니다. 부팅 시 운영 체제는 Windows처럼 필요한 드라이버를 찾아 로드합니다. 세 번째 방법은 운영 체제가 런타임에 새 드라이버를 허용하고 재부팅하지 않고 동적으로 설치할 수 있도록 하는 것입니다. 이 접근 방식은 예전에는 드물었지만 이제는 더욱 보편화되고 있습니다. USB 및 IEEE 1394 장치와 같은 핫 플러그 ​​가능 장치에는 항상 동적으로 로드된 드라이버가 필요합니다.

각 컨트롤러에는 통신에 사용되는 소수의 레지스터가 있습니다. 예를 들어, 최소 디스크 컨트롤러에는 디스크 주소, 메모리 주소, 섹터 수 및 방향(읽기 또는 쓰기)을 지정하는 레지스터가 있을 수 있습니다. 컨트롤러를 활성화하기 위해 드라이버는 운영 체제로부터 명령을 받아 이를 적절한 값으로 변환하고 장치 레지스터에 기록합니다. 모든 장치 레지스터의 집합이 I/O 포트 공간을 구성합니다.

일부 컴퓨터에서는 장치 레지스터가 운영 체제의 주소 공간(사용할 수 있는 주소)에 매핑되므로 일반 메모리 단어처럼 읽고 쓸 수 있습니다. 이러한 컴퓨터에서는 특별한 I/O 명령이 필요하지 않으며 사용자 프로그램은 이러한 메모리 주소를 도달 범위 내에 배치하지 않음으로써(예: 기본 및 제한 레지스터 사용) 하드웨어에서 멀리 떨어져 있을 수 있습니다. 다른 컴퓨터에서는 장치 레지스터가 특수 I/O 포트 공간에 배치되며 각 레지스터에는 포트 주소가 있습니다. 이러한 머신에는 드라이버가 레지스터를 읽고 쓸 수 있도록 하는 커널 모드의 특수 IN 및 OUT 명령어가 있습니다. 전자의 솔루션은 특별한 I/O 명령이 필요하지 않지만 일부 주소 공간을 차지합니다. 후자는 주소 공간을 사용하지 않지만 특별한 지침이 필요합니다. 두 시스템 모두 널리 사용됩니다.

입력과 출력은 세 가지 방법으로 수행될 수 있습니다. 가장 간단한 접근 방식에서는 사용자 프로그램이 시스템 호출을 발행한 후 커널에 의해 해당 드라이버에 대한 프로시저 호출로 변환됩니다. 그런 다음 드라이버는 I/O를 시작하고 긴밀한 루프에서 장치를 계속 폴링하여 작업이 완료되었는지 확인합니다(일반적으로 장치가 여전히 사용 중임을 나타내는 일부 비트가 있습니다). I/O가 완료되면 드라이버는 필요한 곳에 데이터(있는 경우)를 배치하고 반환합니다. 그런 다음 운영 체제는 호출자에게 제어권을 반환합니다. 이 방법을 바쁜 대기라고 하며 완료될 때까지 장치를 폴링하는 CPU를 점유한다는 단점이 있습니다.

두 번째 방법은 드라이버가 장치를 시작하고 완료되면 중단하도록 요청하는 것입니다. 이 시점에서 운전자가 돌아옵니다. 그런 다음 필요한 경우 운영 체제는 호출자를 차단하고 다른 작업을 찾습니다. 컨트롤러가 전송 끝을 감지하면 신호 완료에 대한 인터럽트를 생성합니다.

인터럽트는 운영 체제에서 매우 중요하므로 이 아이디어를 자세히 살펴보겠습니다. 아래 그림 (a)에서는 I/O의 3단계 프로세스를 보여줍니다. 1단계에서 드라이버는 장치 레지스터에 기록하여 컨트롤러에 수행할 작업을 알려줍니다. 그러면 컨트롤러가 장치를 시작합니다. 컨트롤러가 전송하도록 요청된 바이트 수를 읽거나 쓰는 것을 마치면 일부 버스를 사용하여 2단계에서 인터럽트 컨트롤러 칩에 신호를 보냅니다. 인터럽트 컨트롤러가 인터럽트를 받아들일 준비가 되면(더 높은 우선순위의 인터럽트를 처리하는 중이라면 그렇지 않을 수도 있음) 3단계에서 이를 알리기 위해 CPU 칩의 핀을 어설션합니다. 4단계에서 인터럽트 컨트롤러는 CPU가 이를 읽고 방금 완료된 장치를 알 수 있도록 장치 번호를 버스에 넣습니다(많은 장치가 동시에 실행될 수 있음).

CPU가 인터럽트를 수락하기로 결정하면 일반적으로 프로그램 카운터와 PSW가 현재 스택으로 푸시되고 CPU는 커널 모드로 전환됩니다. 장치 번호는 해당 장치에 대한 인터럽트 핸들러의 주소를 찾기 위해 메모리 섹션에 대한 색인으로 사용될 수 있습니다. 메모리의 이 부분을 인터럽트 벡터라고 합니다. 인터럽트 핸들러(인터럽트 장치 드라이버의 일부)가 시작되면 스택 프로그램 카운터와 PSW를 제거하고 저장한 다음 장치에 쿼리하여 상태를 확인합니다. 핸들러가 모두 완료되면 이전에 실행 중인 사용자 프로그램으로 돌아가서 아직 실행되지 않은 첫 번째 명령으로 돌아갑니다. 이러한 단계는 아래 그림 (b)에 나와 있습니다.

(a) I/O 장치를 시작하고 인터럽트를 받는 단계입니다. (b) 인터럽트 처리에는 인터럽트 수락, 인터럽트 핸들러 실행 및 사용자 프로그램으로 복귀가 포함됩니다.

I/O를 수행하는 세 번째 방법은 특수 하드웨어, 즉 지속적인 CPU 개입 없이 메모리와 일부 컨트롤러 사이의 비트 흐름을 제어할 수 있는 DMA(직접 메모리 액세스) 칩을 사용하는 것입니다. CPU는 DMA 칩을 설정하고 전송할 바이트 수, 관련 장치 및 메모리 주소, 방향을 알려주고 이를 수행하도록 합니다. DMA 칩이 완료되면 위에서 설명한 대로 처리되는 인터럽트가 트리거됩니다.

예를 들어, 다른 인터럽트 핸들러가 실행되는 동안 매우 불편한 시간에 인터럽트가 발생할 수 있으며 종종 발생합니다. 따라서 CPU에는 인터럽트를 비활성화한 다음 나중에 다시 활성화하는 방법이 있습니다. 인터럽트가 비활성화되면 완료된 장치는 계속해서 인터럽트 신호를 주장하지만 CPU는 인터럽트가 다시 활성화될 때까지 인터럽트를 수행하지 않습니다. 인터럽트가 비활성화된 동안 여러 장치가 완료되면 인터럽트 컨트롤러는 일반적으로 각 장치에 할당된 정적 우선 순위에 따라 먼저 통과할 장치를 결정합니다. 우선순위가 가장 높은 장치가 우선하여 먼저 제공됩니다. 다른 사람들은 기다려야 할 것입니다.

18.2.5 버스

프로세서와 메모리가 점점 더 빨라짐에 따라 모든 트래픽을 처리하는 단일 버스(물론 IBM PC 버스도 포함)의 능력은 한계에 도달했습니다. 더 빠른 I/O 장치와 CPU-메모리 통신을 위해 추가 버스가 추가되었습니다. 이러한 진화의 결과로 현재 대형 x86 시스템은 아래 이미지와 유사하게 보입니다.

대규모 x86 시스템의 아키텍처.

시스템에는 각각 전송 속도와 기능이 다른 많은 버스(예: 캐시, 메모리, PCIe, PCI, USB, SATA 및 DMI)가 있습니다. 운영 체제는 이를 구성하고 관리하기 위해 이 모든 정보를 알아야 합니다.

메인 버스는 Intel이 기존 PCI 버스의 후속 제품으로 개발한 PCIe(Peripheral Component Interconnect Express) 버스로 원래 ISA(Industry Standard Architecture) 버스를 대체했습니다. 초당 수십 기가비트의 데이터를 전송할 수 있는 PCIe는 이전 제품보다 훨씬 빠르며 속성도 매우 다릅니다. 2004년에 생성될 때까지 대부분의 버스는 병렬 공유, 즉 여러 장치가 동일한 와이어를 사용하여 데이터를 전송하는 공유 버스 아키텍처였습니다. 따라서 여러 장치에 전송할 데이터가 있는 경우 누가 버스를 사용할 수 있는지 결정하는 중재자가 필요합니다. 대신 PCIe는 전용 지점 간 연결을 사용하며 기존 PCI에서 사용되는 병렬 버스 아키텍처는 각 데이터 단어가 여러 와이어를 통해 전송될 수 있음을 의미합니다. 예를 들어 일반 PCI 버스에서는 단일 32비트 숫자가 32개의 병렬 와이어를 통해 전송됩니다. 이와 대조적으로 PCIe는 직렬 버스 아키텍처를 사용하며 네트워크 패킷과 마찬가지로 채널이라고 하는 단일 연결을 통해 메시지의 모든 비트를 보냅니다. 이 방법은 모든 32비트가 동시에 대상에 도착하는지 확인할 필요가 없기 때문에 훨씬 간단합니다. 여러 개의 병렬 레인이 있을 수 있으므로 병렬성은 여전히 ​​사용됩니다. 예를 들어 32개의 레인을 사용하여 32개의 메시지를 병렬로 전송할 수 있습니다. 네트워크 카드, 그래픽 어댑터 등 주변 장치의 속도가 급격히 증가함에 따라 PCIe 표준은 3~5년마다 업그레이드됩니다. 예를 들어 16레인 PCIe 2.0은 초당 64Gb를 제공하며, PCIe 3.0으로 업그레이드하면 속도가 두 배, PCIe 4.0은 속도가 다시 두 배로 늘어납니다.

동시에 위 이미지에 표시된 것처럼 별도의 허브 프로세서에 연결된 이전 PCI 표준에 대한 레거시 장치가 여전히 많이 있습니다. 미래에는 PCI가 더 이상 오래된 것이 아니라 구식이라는 점을 고려할 때 모든 PCI 장치가 다른 허브에 연결되어 다시 메인 허브에 연결되어 버스 트리를 형성할 가능성이 있습니다.

이 구성에서 CPU는 고속 DDR3 버스를 통해 메모리, PCIe를 통해 외부 그래픽 장치, DMI(Direct Media Interface) 버스의 허브를 통해 기타 모든 장치와 통신합니다. 허브는 USB 장치와 통신하는 범용 직렬 버스, 하드 드라이브 및 DVD 드라이브와 상호 작용하는 SATA 버스, 이더넷 프레임 전송을 위한 PCIe를 사용하여 다른 모든 장치를 연결합니다.

또한 각 코어에는 전용 캐시와 더 큰 캐시가 공유되며, 각 코어에는 또 다른 버스가 있습니다.

USB(Universal Serial Bus)는 키보드, 마우스 등 모든 저속 I/O 장치를 컴퓨터에 연결하기 위해 개발되었습니다. 그러나 8Mbps ISA를 최초 IBM PC의 메인 버스로 성장한 세대에게는 5Gbps로 실행되는 최신 USB 3.0 장치를 "저속"이라고 부르는 것이 자연스럽게 이해되지 않을 수도 있습니다. USB는 버전에 따라 4~11개의 와이어가 있는 작은 커넥터를 사용하며, 그 중 일부는 USB 장치에 전원이나 접지를 제공합니다. USB는 루트 장치가 1밀리초마다 모든 I/O 장치를 폴링하여 트래픽이 있는지 확인하는 중앙 집중식 버스입니다. USB 1.0은 12Mbps의 총 로드를 처리할 수 있고, USB 2.0은 속도를 480Mbps로 높이고, USB 3.0은 최고 5Gbps 이상을 처리할 수 있습니다. 모든 USB 장치는 컴퓨터에 연결될 수 있으며 이전에 USB 장치가 필요했던 컴퓨터를 다시 시작할 필요 없이 즉시 작동하므로 좌절한 사용자 세대는 크게 당황합니다.

SCSI(Small Computer System Interface) 버스는 고속 디스크, 스캐너 및 많은 양의 대역폭이 필요한 기타 장치에 사용되는 고성능 버스입니다. 현재는 대부분 서버와 워크스테이션에 있으며 최대 640MB/초의 속도로 실행될 수 있습니다.

위 그림과 같은 환경에서 작업하려면 운영 체제가 컴퓨터에 연결된 주변 장치를 알고 이를 구성해야 합니다. 이러한 요구 사항으로 인해 Intel과 Microsoft는 Apple의 Macintosh에서 처음 구현된 유사한 개념을 기반으로 플러그 앤 플레이(Plug and Play)라는 PC 시스템을 설계하게 되었습니다. 플러그 앤 플레이 이전에는 각 I/O 카드에 고정된 인터럽트 요청 수준과 I/O 레지스터에 대한 고정 주소가 있었습니다. 예를 들어, 키보드는 인터럽트 1이고 I/O 주소 0x60에서 0x64를 사용하고, 플로피 디스크 컨트롤러는 인터럽트 6이고 I/O 위치 0x3F0에서 0x3F7을 사용하고, 프린터는 인터럽트 7이고 I/O 주소 0x378에서 0x37A를 사용하는 식입니다.

지금까지는 너무 좋았습니다. 문제는 사용자가 사운드 카드와 모뎀 카드를 구입하고 두 카드가 우연히 동일한 인터럽트(예: 인터럽트 4)를 사용하는 경우 발생합니다. 즉, 충돌하여 함께 작동할 수 없습니다. 해결책은 각 I/O 카드에 DIP 스위치나 점퍼를 포함하고 사용자 시스템의 다른 주소와 충돌하지 않는 인터럽트 수준과 I/O 장치 주소를 선택하도록 사용자에게 지시하는 것입니다. 불행하게도 그렇게 하는 사람은 거의 없어 혼란을 야기합니다.

플러그 앤 플레이 기능은 시스템이 자동으로 I/O 장치에 대한 정보를 수집하고 중앙에서 인터럽트 수준과 I/O 주소를 할당한 다음 각 카드에 번호를 알려주는 것입니다. 이 작업은 컴퓨터를 시작하는 것과 밀접한 관련이 있으며 간단한 문제가 아닙니다.

18.3 커널

다음 두 그림은 주류 커널의 전체 아키텍처를 보여줍니다.

핵심 아키텍처 1.

커널 아키텍처 2.

다이어그램의 하단 부분은 감독자 모드에서 실행되는 커널 부분(및 거기에서 호출되는 함수)을 보여줍니다. 감독자 모드에서 실행되는 모든 코드는 어셈블러로 작성되며 crt0.S 파일에 포함됩니다. crt0.S의 코드는 시작 코드, 하드웨어 액세스 기능, 인터럽트 서비스 루틴, 작업 스위치(스케줄러) 및 성능상의 이유로 어셈블러로 작성된 세마포어 기능으로 구분됩니다.

다이어그램의 중간 부분은 사용자 모드에서 실행되는 나머지 커널을 보여줍니다. crt0.S의 코드를 호출하려면 모니터 모드로 변경해야 합니다. 즉, 가운데에서 아래쪽으로 향하는 각 화살표는 모니터 모드를 변경하는 하나 이상의 TRAP 명령과 연결되어 있습니다. 클래스 os에는 응용 프로그램이 특정 하드웨어 구성 요소에 액세스할 수 있도록 하는 TRAP 명령어가 포함된 래퍼 함수 세트가 포함되어 있습니다. SerialIn 및 SerialOut 클래스는 직렬 I/O라고 하며 하드웨어 액세스가 필요하고 인터럽트 서비스 루틴에서도 액세스할 수 있습니다. 클래스 태스크에는 태스크 관리와 관련된 모든 것이 포함되어 있으며 (명시적인) 태스크 전환을 위해 커널의 감독자 부분을 사용합니다. 작업 전환은 인터럽트 서비스 루틴에 의해서도 발생합니다. Semaphore 클래스는 사용자 모드에서 해당 멤버 함수의 구현을 사용할 수 있도록 하는 래퍼 함수를 ​​제공합니다. 몇몇 Queue 클래스는 커널에서 내부적으로 사용되며 애플리케이션에서도 사용할 수 있습니다. 대부분은 Semaphore 클래스를 사용합니다.

일반적으로 애플리케이션은 내부 커널 인터페이스와 관련이 없습니다. 커널 관련 인터페이스는 os, SerialIn, SerialOut, Task, Queue 및 Semaphore 클래스에 정의된 인터페이스입니다.

18.3.1 커널 개요

아래 그림은 커널의 주요 구성 요소에 대한 간단한 개요를 제공합니다. 하단에는 칩, 회로 기판, 디스크, 키보드, 모니터 및 유사한 물리적 개체로 구성된 하드웨어가 표시됩니다. 하드웨어 위에는 소프트웨어가 있으며 대부분의 컴퓨터에는 커널 모드와 사용자 모드라는 두 가지 작동 모드가 있습니다. 운영 체제는 소프트웨어의 가장 기본적인 부분이며 커널 모드(감독자 모드라고도 함)에서 실행됩니다. 이 모드에서는 모든 하드웨어에 대한 전체 액세스 권한을 가지며 시스템이 수행할 수 있는 모든 명령을 실행할 수 있습니다. 나머지 소프트웨어는 기계 명령어의 하위 집합만 사용할 수 있는 사용자 모드에서 실행됩니다. 특히 기계 제어에 영향을 미치거나 I/O 입출력을 수행하는 명령어는 사용자 모드 프로그램에서 사용이 "금지"됩니다. 이 기사에서는 운영 체제 작동 방식에서 중요한 역할을 하는 커널 모드와 사용자 모드의 차이점을 반복적으로 설명합니다.

보다 자세한 구조도는 다음과 같습니다.

전통적인 Unix 커널 아키텍처 다이어그램은 다음과 같습니다.

최신 Unix 커널은 다음 아키텍처로 발전했습니다.

샘플 Linux 커널 모듈 목록은 다음과 같습니다.

Linux 커널 구성 요소는 다음과 같습니다.

18.3.2 커널 객체

Windows 커널은 사용자 모드 프로세스, 커널 자체 및 커널 모드 드라이버에서 사용할 수 있는 다양한 유형의 개체를 노출합니다. 이러한 유형의 인스턴스는 사용자 또는 커널 모드 코드가 요청할 때 개체 관리자(실행기의 일부)가 만들고 관리하는 시스템(커널) 공간의 데이터 구조입니다. 커널 객체는 참조 계산을 사용하므로 객체에 대한 마지막 참조가 해제될 때만 객체가 파괴되고 메모리에서 해제됩니다.

Windows 커널은 다양한 개체 유형을 지원합니다. Sysinternals에서 WinObj 도구를 실행하고 ObjectTypes 디렉터리를 찾을 수 있습니다(아래 그림). 가시성과 용도에 따라 분류할 수 있습니다.

  • Windows API를 통해 사용자 모드로 내보낸 유형입니다. 예: 뮤텍스, 세마포어, 파일, 프로세스, 스레드 및 타이머.

  • 사용자 모드로 내보내지지 않지만 장치 드라이버 작성자가 사용할 수 있도록 WDK(Windows 드라이버 키트)에 문서화되어 있는 유형입니다. 장치, 드라이버, 콜백 등.

  • WDK에도 문서화되지 않고(적어도 작성 시점에는) 커널 자체에서만 사용되는 유형입니다. 예로는 분할, 키 이벤트, 핵심 메시징 등이 있습니다.

커널 객체의 주요 속성은 아래 그림에 나와 있습니다.

특정 유형의 객체는 문자열 기반 이름을 가질 수 있으며, 이는 적절한 열기 함수를 사용하여 이름으로 객체를 여는 데 사용할 수 있습니다. 모든 객체에 이름이 있는 것은 아닙니다. 예를 들어 프로세스와 스레드에는 이름이 없고 ID가 있습니다. 이것이 OpenProcess 및 OpenThread 함수에 문자열 기반 이름이 아닌 프로세스/스레드 식별자(숫자)가 필요한 이유입니다.

사용자 모드 코드에서 이름을 가진 생성 함수를 호출하면 해당 이름을 가진 객체가 없으면 해당 이름을 가진 객체가 생성됩니다. 존재하는 경우 기존 객체를 엽니다.

생성 함수에 제공되는 이름은 객체의 최종 이름이 아닙니다. 클래식(데스크톱) 프로세스에서는 앞에 \Sessions\x\BaseNamedObjects\가 옵니다. 여기서 x는 호출자의 세션 ID입니다. 세션이 0이면 이름 앞에 \BaseNamedObjects\가 붙습니다. 호출자가 AppContainer(일반적으로 유니버설 Windows 플랫폼 프로세스)에서 실행 중인 경우 접두사 문자열은 고유한 AppContainerSID: \Sessions\x\AppContaineerNameObjects\로 구성되어 더 복잡합니다.

18.3.3 커널 객체 공유

커널 객체에 대한 핸들은 프로세스 전용이지만 어떤 경우에는 프로세스가 커널 객체를 다른 프로세스와 공유하기를 원할 수도 있습니다. 이러한 프로세스는 단순히 핸들 값을 다른 프로세스에 전달할 수 없습니다. 다른 프로세스의 핸들 테이블에서 핸들 값이 다른 객체를 가리키거나 비어 있을 수 있기 때문입니다. 분명히 이러한 공유를 허용하는 메커니즘이 있어야 합니다. 실제로 세 가지 공유 메커니즘이 있습니다.

  • 이름으로 공유. 가능하다면 이 방법이 가장 쉬운 방법입니다. "사용 가능"은 해당 개체가 이름을 가질 수 있고 가질 수 있음을 의미합니다. 일반적인 시나리오는 협력 프로세스(2개 이상)가 동일한 개체 이름을 사용하여 해당 Create 함수를 호출하는 것입니다. 호출을 수행하는 첫 번째 프로세스는 개체를 생성하고 다른 프로세스의 후속 호출은 동일한 개체에 대한 추가 핸들을 엽니다.

  • 핸들 상속을 통한 공유. 이는 일반적으로 상위 프로세스가 하위 프로세스를 생성할 때 상속된 속성과 데이터를 전달하여 수행됩니다.

  • 복사 핸들을 통해 공유됨. 핸들 복사에는 기본 제한(안전성 제외)이 없으며 이름이 지정되거나 지정되지 않은 거의 모든 커널 개체에서 작동하며 어느 시점에서든 작동할 수 있습니다. 그러나 실제로 공유하기 가장 어려운 방법이라는 단점이 있습니다(나중에 설명). Windows는 DuplicateHandle을 호출하여 핸들을 복사합니다.

Windows 카피 핸들 적용사례 예시입니다.

18.3.4 핸들

커널 개체는 시스템 공간에 있으므로 사용자 모드에서 직접 액세스할 수 없습니다. 애플리케이션은 핸들이라는 커널 객체에 액세스하기 위해 간접적인 메커니즘을 사용해야 합니다. 핸들에는 최소한 다음과 같은 장점이 있습니다.

  • 향후 Windows 버전에서 개체 유형 데이터 구조에 대한 변경 사항은 클라이언트에 영향을 미치지 않습니다.

  • 보안접근검사를 통해 객체에 대한 접근을 통제할 수 있습니다.

  • 핸들은 프로세스 비공개이므로 한 프로세스에서 특정 개체에 대한 핸들을 갖는 것은 다른 프로세스의 컨텍스트에서는 의미가 없습니다.

커널 객체는 참조 계산됩니다. 개체 관리자는 핸들 수와 포인터 수를 유지하며 그 합계는 개체의 총 참조 수입니다(직접 포인터는 커널 모드에서 얻을 수 있음). 사용자 모드 클라이언트에서 사용하는 개체가 더 이상 필요하지 않으면 클라이언트 코드는 CloseHandle을 호출하여 개체에 액세스하는 데 사용되는 핸들을 닫아야 합니다. 그러면 핸들이 유효하지 않게 되고 핸들을 닫아 개체에 액세스하려는 시도가 실패하게 됩니다. 일반적인 경우 클라이언트는 객체가 파괴되었는지 여부를 알 수 없습니다. 개체에 대한 참조가 0으로 떨어지면 개체 관리자는 해당 개체를 삭제합니다.

핸들 값은 4의 배수입니다. 여기서 첫 번째 유효한 핸들은 4입니다. 0은 64비트 시스템에서도 유효한 핸들 값이 아닙니다. 핸들은 핸들에 대한 일부 정보를 포함하는 커널 공간의 작은 데이터 구조를 간접적으로 가리킵니다. 아래 그림은 32비트 및 64비트 시스템의 데이터 구조를 설명합니다.

이 핸들 항목의 크기는 32비트 시스템에서는 8바이트이고 64비트 시스템에서는 16바이트입니다(기술적으로는 12바이트이면 충분하지만 정렬을 위해 16바이트로 확장됩니다). 각 항목에는 다음 구성 요소가 포함되어 있습니다.

  • 실제 객체에 대한 포인터입니다. 하위 비트는 주소 정렬을 통해 태그를 지정하고 CPU 액세스 시간을 향상시키는 데 사용되므로 개체의 주소는 32비트 시스템에서는 8의 배수이고 64비트 시스템에서는 16의 배수입니다.

  • 이 핸들을 사용하여 수행할 수 있는 작업을 나타내는 액세스 마스크입니다. 즉, 액세스 마스킹은 핸들의 힘입니다.

  • 세 가지 플래그: 상속, 종료 시 보호, 종료 시 확인.

액세스 마스크는 각 "1" 비트가 해당 핸들을 사용하여 수행할 수 있는 특정 작업을 나타내는 비트마스크입니다. 액세스 마스크는 객체를 생성하거나 기존 객체를 열어 핸들을 생성할 때 설정됩니다. 객체가 생성되면 호출자는 일반적으로 객체에 대한 전체 액세스 권한을 갖습니다. 그러나 객체가 열리면 호출자는 필요한 액세스 마스크를 지정해야 하며, 이를 얻을 수도 있고 얻지 못할 수도 있습니다.

특정 핸들은 특별한 값을 가지며 닫힐 수 없으며 필요할 때 다른 핸들처럼 사용되지만 의사 핸들이라고 합니다. 의사 핸들에 대한 CloseHandle 호출은 항상 실패합니다.

더 이상 필요하지 않은 핸들은 닫아 두는 것이 중요합니다. 응용 프로그램이 이 작업을 올바르게 수행하지 못하면 "핸들 누수"가 발생할 수 있습니다. 즉, 응용 프로그램이 핸들을 열었지만 닫는 것을 "잊은" 경우 핸들 수가 제어할 수 없을 정도로 증가합니다. 코드를 닫는 것을 잊지 않고 핸들을 관리하는 데 도움이 되는 한 가지 방법은 C++를 사용하여 RAII(Resource Acquisition is 초기화)라는 잘 알려진 관용구를 구현하는 것입니다. 아이디어는 래핑 객체가 소멸될 때 핸들이 닫히도록 보장하기 위해 유형으로 래핑된 핸들에 소멸자를 사용하는 것입니다. 다음은 간단한 핸들 RAII 래퍼입니다.

cpp
struct Handle
{
    explicit Handle(HANDLE h = nullptr) 
        :_h(h) // 초기화(Initialize)
    {}
    // 함수 비활성화(Disable)。
    ~Handle() { Close(); }
    // 및
    Handle(const Handle&) = delete;
    Handle& operator=(const Handle&) = delete;
    
    // ()
    Handle(Handle&& other) : _h(other._h) 
    {
        other._h = nullptr;
    }
    Handle& operator=(Handle&& other) 
    {
        if (this != &other) 
        {
            Close();
            _h = other._h;
            other._h = nullptr;
        }
        return *this;
    }
    
    operator bool() const 
    {
        return _h != nullptr && _h != INVALID_HANDLE_VALUE;
    }
    HANDLE Get() const 
    {
        return _h;
    }
    void Close() 
    {
        if (_h) 
        {
            ::CloseHandle(_h);
            _h = nullptr;
        }
    }

private:
    HANDLE _h;
};

18.3.5 기타 커널 객체

Windows에는 일반적으로 사용되는 다른 커널 개체, 즉 사용자 개체와 GDI 개체가 있습니다. 다음은 이러한 개체와 해당 개체에 대한 핸들에 대한 간략한 설명입니다.

  • 작업 관리자. "사용자 개체" 및 "GDI 개체" 열을 추가하여 프로세스당 이러한 개체 수를 표시할 수 있습니다.

  • 사용자 개체. 사용자 개체는 창(HWND), 메뉴(HNU) 및 후크(HHOOK)입니다. 이러한 객체에 대한 핸들에는 다음과 같은 속성이 있습니다.

참조 카운팅이 없습니다. 사용자 개체를 삭제하는 첫 번째 호출자는 사라졌습니다.

  • 핸들 값의 범위는 윈도우 스테이션 아래에 있습니다. 윈도우 워크스테이션에는 클립보드, 데스크톱 및 Atom 테이블이 포함되어 있습니다. 예를 들어, 이는 데스크톱을 공유하는 모든 응용 프로그램 간에 이러한 개체에 대한 핸들을 자유롭게 전달할 수 있음을 의미합니다.

  • GDI 개체. GDI(그래픽 장치 인터페이스)는 Windows의 원래 그래픽 API입니다. 더 풍부하고 더 나은 API(예: Direct2D)가 있습니다. 예를 들어 장치 컨텍스트(HDC), 펜(HPEN), 브러시(HBRUSH), 비트맵(HBITMAP) 등이 있습니다. 해당 속성은 다음과 같습니다.

참조 카운팅이 없습니다.

  • 핸들은 생성 중에만 유효합니다.

  • 프로세스 간에 공유할 수 없습니다.

18.3.6 인터럽트

모든 운영 체제 커널의 핵심 책임은 시스템의 하드 드라이브와 Blu-ray 디스크, 키보드 및 마우스, 3D 프로세서 및 무선 라디오에 연결된 하드웨어를 관리하는 것입니다. 이 책임을 수행하기 위해 커널은 기계의 다양한 장치와 통신해야 합니다. 프로세서가 통신 중인 하드웨어보다 훨씬 더 빠를 수 있다는 점을 고려하면 커널이 상당히 느린 하드웨어에서 요청하고 응답을 기다리는 것은 이상적이지 않습니다. 대신, 하드웨어의 응답 속도가 상대적으로 느리기 때문에 커널은 실제로 작업이 완료된 후에만 하드웨어를 처리하여 다른 작업을 자유롭게 처리할 수 있어야 합니다.

프로세서는 기계의 전반적인 성능에 영향을 주지 않고 하드웨어와 어떻게 작동합니까? 이 질문에 대한 한 가지 대답은 커널이 주기적으로 시스템의 하드웨어 상태를 확인하고 그에 따라 응답하는 폴링입니다. 그러나 폴링은 하드웨어가 활성 상태인지 준비 상태인지에 관계없이 반복되어야 하기 때문에 오버헤드가 발생합니다. 더 나은 해결책은 주의가 필요할 때 하드웨어가 커널에 신호를 보내는 메커니즘, 즉 인터럽트라는 메커니즘을 제공하는 것입니다. 이 섹션에서는 인터럽트 핸들러라는 특수 함수를 사용하여 인터럽트와 커널이 이에 응답하는 방법에 대해 설명합니다.

중단 없이 그리고 중단 없이 프로그램 제어 흐름.

18.3.6.1 인터럽트 개요

인터럽트를 사용하면 하드웨어가 프로세서에 신호를 보낼 수 있습니다. 예를 들어, 키보드를 입력할 때 키보드 컨트롤러(키보드를 관리하는 하드웨어 장치)는 프로세서에 전기 신호를 보내 운영 체제에 새로 사용 가능한 키를 알립니다. 이러한 전기 신호는 인터럽트입니다. 프로세서는 인터럽트를 수신하고 운영 체제에 신호를 보내 운영 체제가 새로운 데이터에 응답할 수 있도록 합니다. 하드웨어 장치는 프로세서 클럭을 기준으로 비동기적으로 인터럽트를 생성합니다. 이러한 현상은 언제든지 발생할 수 있습니다. 따라서 커널은 인터럽트를 처리하기 위해 언제든지 인터럽트를 수행할 수 있습니다.

인터럽트는 하드웨어 장치의 전기 신호에 의해 물리적으로 생성되며 여러 인터럽트 라인을 프로세서에 대한 단일 라인으로 결합하는 간단한 멀티스레드 칩인 인터럽트 컨트롤러의 입력 핀으로 전달됩니다. 인터럽트를 수신하면 인터럽트 컨트롤러는 프로세서에 신호를 보냅니다. 프로세서는 신호를 감지하고 현재 실행을 중단하여 인터럽트를 처리합니다. 그런 다음 프로세서는 인터럽트가 발생했음을 운영 체제에 알릴 수 있고 운영 체제는 인터럽트를 적절하게 처리할 수 있습니다.

서로 다른 장치는 각 인터럽트와 관련된 고유한 값을 통해 서로 다른 인터럽트와 연결될 수 있으며, 키보드의 인터럽트는 하드 드라이브의 인터럽트와 다르기 때문에 운영 체제는 인터럽트를 구별하고 어떤 하드웨어 장치가 어떤 인터럽트를 발생시켰는지 알 수 있습니다. 그러면 운영 체제는 적절한 핸들러를 사용하여 각 인터럽트를 처리할 수 있습니다.

이러한 인터럽트 값을 흔히 IRQ(인터럽트 요청) 라인이라고 합니다. 각 IRQ 라인에는 숫자 값이 할당됩니다. 예를 들어 클래식 PC에서 IRQ 0은 타이머 인터럽트이고 IRQ 1은 키보드 인터럽트입니다. 그러나 모든 인터럽트 번호가 그렇게 엄격하게 정의되는 것은 아닙니다. 예를 들어 PCI 버스의 장치와 관련된 인터럽트는 동적으로 할당되는 경우가 많습니다. PC가 아닌 다른 아키텍처에도 유사한 인터럽트 값 동적 할당이 있습니다. 중요한 점은 특정 인터럽트가 특정 장치와 연관되어 있고 커널이 이를 알고 있다는 것입니다. 그런 다음 하드웨어는 커널의 주의를 끌기 위해 인터럽트를 발행합니다. "이봐, 새로운 키 입력을 기다리고 있습니다! 이 나쁜 놈들을 읽고 처리하세요!"

인터럽트가 포함된 명령 주기.

18.3.6.2 인터럽트 요청 수준

각 하드웨어 인터럽트는 HAL에 의해 결정되는 IRQL(인터럽트 요청 수준)(IRQ라고 하는 인터럽트의 물리적 라인과 혼동하지 마십시오)이라는 우선 순위 수준과 연결됩니다. 각 프로세서 컨텍스트에는 레지스터와 마찬가지로 자체 IRQL이 있습니다. IRQL은 CPU 하드웨어에 의해 구현될 수도 있고 구현되지 않을 수도 있지만 그 자체로는 중요하지 않습니다. IRQL은 다른 CPU 레지스터처럼 취급되어야 합니다.

기본 규칙은 프로세서가 IRQL이 가장 높은 코드를 실행한다는 것입니다. 예를 들어, CPU의 IRQL이 어느 시점에서 0이고 IRQL이 5인 인터럽트가 발생하면 현재 스레드의 커널 스택에 상태(컨텍스트)를 저장하고 IRQL을 5로 올린 다음 해당 인터럽트와 관련된 ISR을 실행합니다. ISR이 완료되면 IRQL은 이전 수준으로 떨어지며 마치 인터럽트가 존재하지 않는 것처럼 이전에 실행된 코드를 계속 실행합니다. ISR이 실행되는 동안 IRQL이 5 이하인 다른 인터럽트는 이 프로세서를 인터럽트할 수 없습니다. 반면에 새 인터럽트의 IRQL이 5보다 높으면 CPU는 상태를 다시 저장하고 IRQL을 새 수준으로 올린 다음 두 번째 인터럽트와 관련된 두 번째 ISR을 실행하고 완료되면 IRQL 5로 돌아가 상태를 복원하고 원래 ISR을 계속 실행합니다. 기본적으로 IRQL을 높이면 IRQL 이하의 IRQL이 있는 코드가 일시적으로 차단됩니다. 인터럽트가 발생할 때 발생하는 기본 이벤트 순서는 아래 그림에 나와 있으며, 인터럽트 중첩이 어떻게 나타나는지 보여줍니다.

기본적인 인터럽트 스케줄링 프로세스.

중첩된 인터럽트 프로세스.

위의 두 그림에 표시된 시나리오에 대한 중요한 사실은 모든 ISR의 실행이 원래 중단된 동일한 스레드에 의해 완료된다는 것입니다. Windows에는 인터럽트를 처리하는 특수 스레드가 없으며 당시 인터럽트 프로세서에서 실행 중인 스레드에 의해 처리됩니다. 프로세서의 IRQL이 2 이상이면 컨텍스트 전환이 불가능하므로 이러한 ISR이 실행되는 동안 다른 스레드가 몰래 들어올 수 없다는 것을 빨리 알 수 있습니다.

이러한 "중단"으로 인해 중단된 스레드의 수가 줄어들지 않습니다. 틀림없이 그것은 그것의 잘못이 아니다.

사용자 모드 코드가 실행될 때 IRQL은 항상 0입니다. 이는 사용자 모드 문서에서 IRQL이라는 용어가 언급되지 않는 이유 중 하나입니다. 항상 0이며 변경할 수 없습니다. 대부분의 커널 모드 코드는 IRQL 0으로도 실행되며, 커널 모드에서는 현재 프로세서에서 IRQL을 올릴 수 있습니다.

API를 사용하여 IRQL을 높이거나 낮추는 Windows의 예:

cpp
// assuming current IRQL <= DISPATCH_LEVEL
KIRQL oldIrql; // typedefed as UCHAR

// IRQL
KeRaiseIrql(DISPATCH_LEVEL, &oldIrql);

NT_ASSERT(KeGetCurrentIrql() == DISPATCH_LEVEL);

// IRQL의 DISPATCH_LEVEL실행(Execute)。

// IRQ
KeLowerIrql(oldIrql);

18.3.6.3 스레드 우선순위 및 IRQL

IRQL은 프로세서의 속성이고 우선 순위는 스레드의 속성이며 스레드 우선 순위는 IRQL <2인 경우에만 의미가 있습니다. 실행 스레드가 IRQL을 2 이상으로 높이면 우선 순위는 더 이상 의미가 없으며 이론적으로 범위는 무한합니다. IRQL이 2 아래로 떨어질 때까지 계속 실행됩니다.

물론 IRQL >= 2에서 많은 시간을 보내는 것은 좋은 것이 아닙니다. 사용자 모드 코드는 확실히 실행되지 않습니다. 이는 이러한 수준에서 코드를 실행하는 기능이 심각하게 제한되는 이유 중 하나일 뿐입니다.

Windows 작업 관리자는 프로세스 탐색기에서 "인터럽트"라고 부르는 시스템 인터럽트라는 의사 프로세스를 사용하여 IRQL2 이상에서 소비된 CPU 시간을 표시합니다. 아래 이미지의 위쪽 절반은 작업 관리자의 스크린샷을 보여주고, 아래쪽 절반은 프로세스 탐색기의 동일한 정보를 보여줍니다.

18.3.6.4 인터럽트 핸들러

특정 인터럽트에 응답하여 커널이 실행하는 기능을 인터럽트 핸들러 또는 **인터럽트 서비스 루틴(ISR)**이라고 합니다. 인터럽트를 생성하는 각 장치에는 연관된 인터럽트 핸들러가 있습니다. 예를 들어, 한 함수는 시스템 타이머의 인터럽트를 처리하고 다른 함수는 키보드에서 생성된 인터럽트를 처리합니다. 장치의 인터럽트 핸들러는 장치 드라이버(장치를 관리하는 커널 코드)의 일부입니다.

Linux에서 인터럽트 핸들러는 일반적인 C 함수입니다. 커널이 표준 방식으로 처리기 정보를 전달할 수 있도록 하는 특정 프로토타입과 일치하지만 그렇지 않으면 일반 함수입니다. 인터럽트 핸들러는 커널이 인터럽트에 대한 응답으로 호출하고 인터럽트 컨텍스트라는 특수 컨텍스트에서 실행된다는 점에서 다른 커널 함수와 다릅니다. 이 특수 컨텍스트를 원자 컨텍스트라고도 하며 이 컨텍스트에서 실행되는 코드는 차단할 수 없습니다.

인터럽트는 언제든지 발생할 수 있으므로 인터럽트 핸들러는 언제든지 실행될 수 있으며 핸들러는 인터럽트된 코드의 실행을 최대한 빨리 재개하기 위해 빠르게 실행되어야 합니다. 따라서 운영 체제 서비스가 지연 없이 인터럽트를 수행하는 것이 하드웨어에 중요하지만, 인터럽트 핸들러가 가능한 가장 짧은 시간에 실행되는 시스템의 나머지 부분에도 중요합니다.

최소한 인터럽트 핸들러의 임무는 하드웨어에 인터럽트 수신을 확인하는 것입니다. "하드웨어, 잘 들었습니다. 이제 다시 작업을 시작하세요!" 그러나 인터럽트 핸들러는 네트워크 장치의 인터럽트 핸들러를 고려하는 등 많은 작업을 수행해야 하는 경우가 많습니다. 하드웨어에 응답하는 것 외에도 인터럽트 핸들러는 네트워크 패킷을 하드웨어에서 메모리로 복사하고 처리한 다음 해당 패킷을 적절한 프로토콜 스택이나 애플리케이션으로 푸시해야 합니다. 분명히 이것은 특히 오늘날의 기가비트 및 10기가비트 이더넷 카드의 경우 많은 작업이 필요할 수 있습니다.

인터럽트를 통해 제어를 전송합니다.

Linux 드라이버에서 인터럽트 라인을 요청하고 핸들러를 설치하는 것은 request_irq()를 통해 수행됩니다.

cpp
if(request_irq(irqn, my_interrupt, IRQF_SHARED, "my_device", my_dev)) 
{
    printk(KERN_ERR "my_device: cannot register IRQ %d\n", irqn);
    return -EIO;
}

18.3.6.5 인터럽트 컨텍스트

인터럽트 핸들러가 실행될 때 커널은 인터럽트 컨텍스트에 있습니다. 이는 커널이 프로세스를 대신하여 실행하는 작업 모드입니다(예: 시스템 호출 실행 또는 커널 스레드 실행). 프로세스 컨텍스트에서 현재 매크로는 관련 작업을 가리킵니다. 또한 프로세스는 프로세스 컨텍스트에서 커널에 결합되므로 프로세스 컨텍스트는 휴면 상태이거나 스케줄러를 호출할 수 있습니다.

반면에 인터럽트 컨텍스트는 프로세스 독립적이며 현재 매크로는 관련이 없습니다(인터럽트된 프로세스를 가리키더라도). 지원 프로세스가 없으면 인터럽트 컨텍스트는 잠자기 상태가 될 수 없습니다. 어떻게 다시 예약됩니까? 따라서 일부 함수는 인터럽트 컨텍스트에서 호출할 수 없으며 함수가 Sleep 상태인 경우 인터럽트 핸들러에서 사용할 수 없으므로 인터럽트 핸들러에서 호출할 수 있는 함수가 제한됩니다.

인터럽트 핸들러가 다른 코드를 인터럽트하기 때문에 인터럽트 컨텍스트는 시간이 중요합니다. 코드는 빠르고 간단해야 합니다. 사용 중 루프는 가능하지만 권장되지 않습니다. 인터럽트 핸들러는 다른 코드를 인터럽트한다는 점을 기억하십시오(어쩌면 다른 라인에 있는 또 다른 인터럽트 핸들러일 수도 있습니다!). 이러한 비동기적 특성으로 인해 모든 인터럽트 핸들러는 최대한 빠르고 단순해야 합니다. 가능한 한 작업을 인터럽트 핸들러 밖으로 밀어내고 후반부에 실행하여 보다 편리한 시간에 실행되도록 해야 합니다.

인터럽트 핸들러 스택 설정은 구성 옵션입니다. 역사적으로 인터럽트 핸들러는 자체 스택을 받지 못했습니다. 대신 그들은 인터럽트의 프로세스 스택을 공유합니다. 커널 스택 크기는 두 페이지로, 일반적으로 32비트 아키텍처에서는 8KB, 64비트 아키텍처에서는 16KB입니다. 인터럽트 핸들러는 이 설정에서 스택을 공유하므로 데이터 할당에 있어 더욱 경제적이어야 합니다. 물론 커널 스택은 처음부터 제한되어 있으므로 모든 커널 코드는 주의해서 사용해야 합니다.

커널 프로세스 초기에 스택 크기는 2페이지에서 1페이지로 줄어들 수 있으며 32비트 시스템에서는 4KB 스택만 제공됩니다. 이전에는 시스템의 각 프로세스에 두 페이지의 연속된 비확장 커널 메모리가 필요했기 때문에 이러한 이동으로 인해 메모리 부담이 줄어듭니다. 감소된 스택 크기를 처리하기 위해 인터럽트 핸들러에는 프로세서당 하나의 스택, 하나의 페이지 크기로 구성된 자체 스택이 제공됩니다. 이 스택을 인터럽트 스택이라고 합니다. 인터럽트 스택의 전체 크기는 원래 공유 스택의 절반이지만 인터럽트 핸들러가 전체 메모리 페이지를 자체적으로 획득할 수 있기 때문에 사용 가능한 평균 스택 공간은 더 큽니다.

인터럽트 핸들러는 사용되는 스택 설정이나 커널 스택 크기에 신경 쓰지 않아야 하며 항상 절대 최소 스택 공간을 사용해야 합니다.

18.3.6.6 인터럽트 핸들러 구현

Linux에서 인터럽트 처리 시스템의 구현은 아키텍처에 따라 다릅니다. 구현은 프로세서, 사용된 인터럽트 컨트롤러 유형, 아키텍처 및 시스템 설계에 따라 다릅니다. 아래 그림은 하드웨어와 커널을 통한 인터럽트의 경로 다이어그램이다.

장치는 버스를 통해 전기 신호를 인터럽트 컨트롤러로 전송하여 인터럽트를 발생시킵니다. 인터럽트 컨트롤러는 인터럽트 라인이 활성화된 경우 프로세서로 인터럽트를 보냅니다. 대부분의 아키텍처에서 이는 특수 핀을 통해 프로세서로 전송되는 전기 신호를 통해 수행됩니다. 프로세서에서 인터럽트가 비활성화되지 않는 한, 프로세서는 수행 중인 작업을 즉시 중지하고 인터럽트 시스템을 비활성화하며 메모리의 미리 정의된 위치로 점프하여 해당 위치에 있는 코드를 실행합니다. 이 미리 정의된 지점은 커널에 의해 설정되며 인터럽트 핸들러의 진입점입니다.

커널의 인터럽트 프로세스는 미리 정의된 예외 처리기를 통해 시스템 호출이 커널에 들어가는 것처럼 미리 정의된 진입점에서 시작됩니다. 각 인터럽트 라인에 대해 프로세서는 메모리의 고유한 위치로 점프하고 해당 위치에 있는 코드를 실행합니다. 이러한 방식으로 커널은 들어오는 인터럽트의 IRQ 번호를 알고, 초기 진입점은 단순히 해당 값을 저장하고 현재 레지스터 값(인터럽트에 속하는 작업)을 스택에 저장한 다음 커널이 do_IRQ()를 호출합니다. 이제부터 대부분의 인터럽트 처리 코드는 C로 작성되지만 여전히 아키텍처에 따라 다릅니다. IRQ를 처리하는 코드는 다음과 같습니다.

cpp
/**
* handle_IRQ_event - irq action chain handler
* @irq: the interrupt number
* @action: the interrupt action chain for this irq
*
* Handles the action chain of an irq event
*/
irqreturn_t handle_IRQ_event(unsigned int irq, struct irqaction* action)
{
    irqreturn_t ret, retval = IRQ_NONE;
    unsigned int status = 0;
    if (!(action->flags & IRQF_DISABLED))
        local_irq_enable_in_hardirq();
    do {
        trace_irq_handler_entry(irq, action);
        ret = action->handler(irq, action->dev_id);
        trace_irq_handler_exit(irq, action, ret);
        switch (ret) {
        case IRQ_WAKE_THREAD:
            /*
            * Set result to handled so the spurious check
            * does not trigger.
            */
            ret = IRQ_HANDLED;
            /*
            * Catch drivers which return WAKE_THREAD but
            * did not set up a thread function
            */
            if (unlikely(!action->thread_fn)) {
                www.it - ebooks.info
                warn_no_thread(irq, action);
                break;
            }
            /*
            * Wake up the handler thread for this
            * action. In case the thread crashed and was
            * killed we just pretend that we handled the
            * interrupt. The hardirq handler above has
            * disabled the device interrupt, so no irq
            * storm is lurking.
            */
            if (likely(!test_bit(IRQTF_DIED,
                &action->thread_flags))) {
                set_bit(IRQTF_RUNTHREAD, &action->thread_flags);
                wake_up_process(action->thread);
            }
            /* Fall through to add to randomness */
        case IRQ_HANDLED:
            status |= action->flags;
            break;
        default:
            break;
        }
        retval |= ret;
        action = action->next;
    } while (action);
    if (status & IRQF_SAMPLE_RANDOM)
        add_interrupt_randomness(irq);
    local_irq_disable();
    return retval;
}

18.3.6.7 지연된 프로시저 호출

다음 그림은 클라이언트가 일부 I/O 작업을 호출할 때 발생하는 일반적인 이벤트 순서를 보여줍니다. 그림에서 사용자 모드 스레드는 파일에 대한 핸들을 열고 ReadFile 함수를 사용하여 읽기 작업을 실행합니다. 스레드는 비동기 호출을 수행할 수 있으므로 거의 즉시 제어권을 되찾고 다른 작업을 수행할 수 있습니다. 이 요청을 받은 드라이버는 요청이 디스크 드라이버에 도달할 때까지 파일 시스템 드라이버(예: NTFS)를 호출하고, 이 드라이버는 실제 디스크 하드웨어에서 작업을 시작합니다. 이 시점에서는 하드웨어가 "자기 작업을 수행"하므로 코드를 실행할 필요가 없습니다.

하드웨어가 읽기 작업을 완료하면 인터럽트를 발행하여 인터럽트와 관련된 인터럽트 서비스 루틴이 IRQL 장치에서 실행되도록 합니다(인터럽트가 비동기적으로 도착하므로 요청을 처리하는 스레드는 임의적입니다). 일반적인 ISR은 장치의 하드웨어에 액세스하여 작업 결과를 얻으며 최종 작업은 원래 요청을 완료하는 것입니다.

간단한 인터럽트 처리 프로세스.

18.3.6.8 비동기 프로시저 호출

Windows에서 DPC(지연 프로시저 호출)는 IRQLDISPATCH_LEVEL에서 호출되는 함수를 캡슐화하는 개체입니다. DPC에 관한 한 호출 스레드는 중요하지 않습니다. **APC(Asynchronous Procedure Call)**도 호출할 함수를 캡슐화하는 데이터 구조입니다. 그러나 DPC와 달리 APC는 특정 스레드를 대상으로 하므로 해당 스레드만 기능을 실행할 수 있습니다. 즉, 각 스레드에는 이와 관련된 APC 대기열이 있습니다. APC에는 세 가지 유형이 있습니다.

  • 사용자 모드 APC. 이러한 APC는 일반적으로 SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx 및 유사한 API와 같은 API를 호출하여 스레드가 경고 가능 상태에 들어갈 때만 IRQL PASSIVE_LEVEL의 사용자 모드에서 실행됩니다. 이 함수의 마지막 매개변수를 TRUE로 설정하여 스레드를 경고 가능 상태로 만들 수 있습니다. 이 상태에서는 APC 큐를 살펴보고 비어 있지 않으면 큐가 빌 때까지 APC가 실행됩니다.

  • 일반 커널 모드 APC. 이러한 APC는 IRQL PASSIVE_LEVEL의 커널 모드에서 실행되며 사용자 모드 코드 및 사용자 모드 APC를 선점합니다.

  • 특수 커널 APC. 이러한 APC는 IRQL APC_LEVEL(1)의 커널 모드에서 실행되며 사용자 모드 코드, 일반 커널 APC 및 사용자 모드 APC를 선점합니다. I/O 시스템은 이러한 APC를 사용하여 I/O 작업을 완료합니다.

18.4 프로세스

이 장에서는 다양한 운영 체제에서 프로세스의 개념, 특성 및 기술 내부 정보에 대해 설명합니다.

18.4.1 프로세스 모델

이 모델에서는 때로는 운영 체제를 포함하여 컴퓨터에서 실행되는 모든 소프트웨어가 순차적 프로세스 또는 단순한 프로세스로 구성됩니다. 프로세스는 프로그램 카운터, 레지스터 및 변수의 현재 값을 포함하여 실행 중인 프로그램의 인스턴스일 뿐입니다. 개념적으로 각 프로세스에는 자체 가상 CPU가 있습니다. 물론 실제로는 실제 CPU가 프로세스 간에 앞뒤로 전환하지만 시스템을 이해하려면 CPU가 프로그램 간에 전환하는 방식을 추적하는 것보다 (의사) 병렬 방식으로 실행되는 프로세스 모음을 생각하는 것이 훨씬 쉽습니다. 이렇게 빠르게 앞뒤로 전환하는 것을 다중 프로그래밍이라고 합니다.

아래 그림 (a)에서는 메모리에 4개의 프로그램을 다중 프로그래밍하는 컴퓨터가 있습니다. 아래 그림 (b)에서는 각각 자체 제어 흐름(즉, 자체 논리 프로그램 카운터)이 있고 서로 독립적으로 실행되는 4개의 프로세스를 볼 수 있습니다. 물론 물리적 프로그램 카운터는 하나만 있으므로 각 프로세스가 실행될 때 해당 논리적 프로그램 카운터가 실제 프로그램 카운터에 로드됩니다. 완료되면(임시로) 물리적 프로그램 카운터가 메모리에 있는 프로세스의 저장된 논리적 프로그램 카운터에 저장됩니다. 아래 그림 (c)에서 우리는 충분히 긴 시간 간격에 걸쳐 모든 프로세스가 진행되지만 특정 순간에 실제로는 하나의 프로세스만 실행되고 있음을 볼 수 있습니다.

(a) 4개의 프로그램을 다중 프로그래밍합니다. (b) 4개의 독립적이고 순차적인 프로세스의 개념적 모델. (c) 한 번에 하나의 프로그램만 활성화됩니다.

프로세스나 작업은 실행 중인 프로그램의 인스턴스이며 프로세스의 실행은 프로그래밍 순서대로 이루어져야 합니다. 프로그램 카운터의 값과 프로세서 레지스터의 내용으로 표시되는 현재 활동뿐만 아니라 임시 데이터(메소드 매개 변수 반환 주소 및 지역 변수 등)가 포함된 프로세스 스택과 전역 변수가 포함된 데이터 세그먼트를 포함하여 언제든지 최대 하나의 명령이 실행됩니다. 프로세스에는 프로세스가 실행되는 동안 동적으로 할당되는 메모리인 힙이 포함될 수도 있습니다.

기존 프로세스 구현 및 메모리 구조.

프로세스와 프로그램의 차이점: 프로그램 자체는 프로세스가 아니며, 실행되는 프로그램을 프로세스라고 합니다. 프로그램은 디스크에 저장된 파일의 내용과 같은 수동적 엔터티인 반면, 프로세스는 실행될 다음 명령을 지정하는 프로그램 카운터와 여러 프로세스 간에 공유할 수 있는 관련 리소스 집합을 포함하는 활성 엔터티입니다. 일정 알고리즘을 사용하여 한 프로세스를 중지하고 다른 프로세스를 서비스할 시기를 결정합니다.

프로세스가 실행되면 상태가 변경되고 해당 상태는 해당 프로세스의 올바른 활동에 의해 정의됩니다. 각 프로세스는 다음 상태 중 하나일 수 있습니다.

  • 신규: 프로세스가 생성되고 있습니다.

  • 준비: 프로세스가 프로세서에 할당되기를 기다리고 있습니다.

  • 런닝맨 : 명령을 실행하는 중입니다.

  • 대기(Waiting): 프로세스가 어떤 이벤트가 발생하기를 기다리고 있습니다.

  • 종료됨:: 프로세스 실행이 완료되었습니다.

많은 프로세스가 동시에 준비 및 대기 상태에 있을 수 있지만 언제든지 하나의 프로세서에서는 하나의 프로세스만 실행될 수 있습니다. 다음 그림은 프로세스의 다양한 상태 간 전환을 보여줍니다.

RTOS+의 프로세스 상태 전환은 다음과 같습니다.

Linux 프로세스 상태는 다음과 같이 전환됩니다.

2단계 프로세스 모델:

5단계 프로세스 모델:

프로세스 큐 모델 샘플 다이어그램:

하나 또는 두 개의 일시 중지 상태가 있는 프로세스 상태 전환 다이어그램:

Unix 프로세스 상태 전환 테이블:

18.4.2 프로세스 제어 블록

각 프로세스는 운영 체제에서 프로세스 제어 블록(PCB)으로 표시되며 프로세스 제어 블록에 의해 제어되기도 합니다. 작업 제어 블록이라고도 하는 프로세스 제어 블록에는 다음을 포함하여 특정 프로세스와 관련된 많은 정보가 포함되어 있습니다.

  • 프로세스 상태: 상태는 신규, 준비, 실행 중, 대기 중 또는 종료됨일 수 있습니다.

  • 프로그램 카운터: 이 목적을 위해 실행될 다음 명령어의 주소를 나타냅니다.

  • CPU 레지스터: 레지스터의 수와 유형은 컴퓨터 아키텍처에 따라 다릅니다. 여기에는 누산기, 인덱스 레지스터, 스택 포인터 및 범용 레지스터와 더불어 인터럽트가 발생할 때 나중에 올바르게 처리를 계속하기 위해 저장해야 하는 모든 조건 코드 정보가 포함됩니다.

  • CPU 스케줄링 정보: 이 정보에는 스케줄링 큐의 프로세스 우선순위 포인터와 기타 스케줄링 매개변수가 포함됩니다.

  • 메모리 관리 정보: 운영 체제에서 사용하는 메모리 시스템에 따라 이 정보에는 스트립 및 제한 레지스터 값, 페이지 테이블 또는 세그먼트 테이블과 같은 정보가 포함될 수 있습니다.

  • 계정 정보: 이 정보에는 CPU 번호 및 실시간 사용 시간, 사용 시간 제한, 계정 번호, 작업 또는 프로세스 번호 등이 포함됩니다.

  • I/O 상태 정보: 이 정보에는 이 프로세스에 할당된 I/O 장치 목록, 열린 파일 목록 등이 포함됩니다. PCB는 단순히 프로세스마다 다를 수 있는 모든 정보의 저장소로 사용됩니다.

CPU는 PCB를 사용하여 프로세스 실행을 전환합니다.

18.4.3 컨텍스트 전환

CPU가 다른 프로세스로 전환되면 시스템은 이전 프로세스의 상태를 저장하고 저장된 새 프로세스의 상태를 로드해야 합니다. 이 동작을 컨텍스트 전환이라고 합니다. 컨텍스트 전환은 시간이 많이 걸리며 전환 중에 시스템은 유용한 작업을 수행하지 않습니다. 스위칭 속도는 메모리 속도, 복사해야 하는 레지스터 수, 특수 명령의 유무에 따라 기계마다 다르며 일반적인 속도는 몇 밀리초입니다. 컨텍스트 전환 시간은 하드웨어 지원에 따라 크게 달라집니다.

18.4.4 프로세스 구성

프로세스는 프로그램의 실행 중인 인스턴스를 나타내는 포함 및 관리 개체입니다. 자주 사용되는 "프로세스 실행"이라는 용어는 정확하지 않습니다. 프로세스는 실제로 실행되지 않고 관리됩니다. 스레드는 코드를 실행하고 기술적으로 실행하는 것입니다. 높은 수준의 관점에서 프로세스에는 다음과 같은 특징이 있습니다.

  • 프로세스에서 코드를 실행하는 데 사용되는 초기 코드와 데이터가 포함된 실행 프로그램입니다.

  • 프로세스 내의 코드에 필요한 모든 목적을 위해 메모리를 할당하는 데 사용되는 개인 가상 주소 공간입니다.

  • 액세스 토큰(기본 토큰이라고도 함)은 프로세스의 기본 보안 컨텍스트를 저장하는 개체이며 프로세스 내에서 코드를 실행하는 스레드에서 사용됩니다(스레드가 가장을 통해 다른 토큰을 사용하지 않는 한).

  • 이벤트, 세마포어, 파일 등의 실행(커널) 개체에 대한 개인 핸들 테이블입니다.

  • 하나 이상의 실행 스레드. 스레드(실행 프로세스의 주요 진입점)를 사용하여 일반 사용자 모드 프로세스를 만듭니다. 스레드가 없는 사용자 모드 프로세스는 일반적으로 쓸모가 없으며 일반적으로 커널에 의해 파괴됩니다.

프로세스의 중요한 부분입니다.

프로세스의 요구 사항을 해결합니다.

운영 체제 제어 테이블의 기존 구조.

Windows 프로세스 및 해당 리소스.

프로세스는 커널 프로세스 개체가 존재하는 한 고유하게 유지되는 프로세스 ID로 고유하게 식별됩니다. 일단 폐기되면 동일한 ID를 새 프로세스에 재사용할 수 있습니다. 실행 파일 자체는 프로세스의 고유 식별자가 아닙니다. 예를 들어, Windows 메모장(notepad.exe)에는 동시에 5개의 exe 인스턴스가 실행될 수 있습니다. 각 프로세스에는 고유한 주소 공간, 스레드, 핸들 테이블, 프로세스 ID 등이 있습니다. 이 5개 프로세스는 모두 초기 코드 및 데이터로 동일한 이미지 파일(notepad.exe)을 사용하지만 각 인스턴스에는 고유한 속성이 있습니다.

DLL(동적 링크 라이브러리)은 코드, 데이터 및 리소스(이 중 하나 이상)를 포함할 수 있는 실행 파일입니다. DLL은 프로세스 초기화(정적 연결이라고 함) 시 또는 명시적으로 요청될 때(동적 연결) 프로세스에 동적으로 로드됩니다. DLL은 실행 파일과 같은 표준적인 주요 기능을 포함하지 않기 때문에 직접 실행할 수 없습니다. DLL을 사용하면 동일한 DLL을 사용하는 여러 프로세스 간에 실제 메모리의 코드를 공유할 수 있습니다. 아래 이미지는 동일한 물리적(및 가상) 주소에 매핑되는 공유 DLL을 사용하는 두 프로세스를 보여줍니다.

Windows NT가 처음 출시된 이후 프로세스의 기본 구조와 속성은 변경되지 않았지만 특별한 동작이나 구조를 가진 새로운 프로세스 유형이 시스템에 도입되었습니다. 다음은 현재 지원되는 모든 프로세스 유형에 대한 간략한 개요입니다.

  • 보호된 프로세스. Windows Vista에서 도입되었습니다. 이는 DRM(디지털 권한 관리)으로 보호된 콘텐츠를 렌더링하는 프로세스에 대한 침입적인 액세스를 방지하여 디지털 권한 관리를 지원하기 위해 만들어졌습니다. 예를 들어, 다른 프로세스(관리자 권한으로 실행 중이더라도)는 보호된 프로세스의 주소 공간이 있는 메모리를 읽을 수 없으므로 DRM으로 보호된 데이터를 직접 도난당할 수 없습니다.

  • UWP 프로세스. Windows 8부터 사용 가능하며 Windows 런타임을 호스팅하고 일반적으로 Microsoft Store에 게시됩니다. UWP 프로세스는 프로세스가 수행할 수 있는 작업을 제한하는 샌드박스인 AppContainer에서 실행됩니다.

  • Protected Process Light(PPL). Vista의 보호 메커니즘을 확장하여 여러 수준의 보호를 추가하고 타사 서비스가 PPL로 실행되도록 허용하여 관리 수준 프로세스에서도 침입적인 액세스 및 종료로부터 서비스를 보호합니다.

  • 최소 프로세스. 주소 공간에 일반 프로세스에 포함된 일반적인 이미지와 데이터 구조가 포함되지 않은 진정한 새로운 형태의 프로세스입니다. 예를 들어, 프로세스 주소 공간에 매핑된 실행 파일과 DLL이 없으며 프로세스 주소 공간은 사실상 비어 있습니다.

  • 피코 프로세스. 이러한 프로세스는 Linux 시스템 호출을 가로채서 이를 동등한 Windows 시스템 호출로 변환하는 커널 드라이버인 마이크로프로바이더만 추가된 최소 프로세스입니다. 이러한 프로세스는 Windows 10 버전 1607에서 사용할 수 있는 WSL(Linux용 Windows 하위 시스템)용입니다.

18.4.5 프로세스 스케줄링

아래 그림과 같이 거의 모든 프로세스는 계산을 위해 (디스크 또는 네트워크) I/O 요청을 번갈아 사용합니다. 일반적으로 CPU는 잠시 동안 멈추지 않고 실행된 후 파일을 읽거나 파일을 쓰기 위해 시스템 호출을 수행합니다. 시스템 호출이 완료되면 CPU는 더 많은 데이터가 필요하거나 더 많은 데이터를 써야 할 때까지 다시 계산합니다. 일부 I/O 활동은 계산으로 간주됩니다. 예를 들어, CPU가 화면을 업데이트하기 위해 비트를 비디오 RAM에 복사하는 경우 CPU가 사용 중이기 때문에 I/O를 수행하는 것이 아니라 컴퓨팅을 수행하는 것입니다. 이러한 의미에서 I/O는 프로세스가 차단 상태에 들어가 외부 장치가 작업을 완료할 때까지 기다리는 것입니다.

CPU 사용량 급증은 I/O 대기 시간과 번갈아 나타납니다. (a) CPU 바인딩 프로세스. (b) I/O 제한 프로세스.

위 그림에서 주목해야 할 중요한 점은 (a)의 프로세스와 같은 일부 프로세스는 대부분의 시간을 계산에 소비하는 반면, (b)의 프로세스와 같은 다른 프로세스는 I/O를 기다리는 데 많은 시간을 소비한다는 것입니다.

전자를 컴퓨팅 제한 또는 CPU 제한이라고 합니다. 후자를 I/O 제한이라고 합니다. 컴퓨팅 바인딩 프로세스는 일반적으로 CPU 버스트가 길어서 I/O 대기가 거의 없는 반면, I/O 바인딩 프로세스는 CPU 버스트가 짧아서 I/O 대기가 자주 발생합니다. 중요한 요소는 I/O 버스트 길이가 아니라 CPU 버스트 길이입니다. I/O 바인딩된 프로세스는 특별히 긴 I/O 요청을 가지고 있기 때문이 아니라 I/O 요청 간에 많은 계산을 수행하지 않기 때문에 I/O 바인딩되어 있습니다. 디스크 블록을 읽기 위해 하드웨어 요청을 보내는 데는 데이터가 도착한 후 데이터를 처리하는 데 걸리는 시간에 관계없이 동일한 시간이 걸립니다.

CPU가 빨라질수록 프로세스에 더 많은 I/O 바인딩이 발생하는 경향이 있다는 점은 주목할 가치가 있습니다. 이러한 효과는 CPU가 디스크보다 훨씬 빠르게 향상되기 때문에 발생합니다. 따라서 I/O 바인딩된 프로세스의 스케줄링은 앞으로 더욱 중요한 주제가 될 수 있습니다. 여기서의 기본 아이디어는 I/O 바인딩된 프로세스를 실행하려는 경우 디스크 요청을 하고 디스크를 계속 사용할 수 있을 만큼 빠르게 기회를 얻을 수 있어야 한다는 것입니다. 프로세스가 I/O 바인딩되면 CPU를 완전히 점유하기 위해 꽤 많은 프로세스가 필요합니다.

프로세스 스케줄링과 관련된 주요 문제는 프로세스 스케줄링 전략을 언제 수립해야 하는가입니다. 다양한 상황에서 일정 조정이 필요한 것으로 나타났습니다. 먼저, 새 프로세스를 생성할 때 상위 프로세스를 실행할지, 하위 프로세스를 실행할지 결정해야 합니다. 두 프로세스 모두 준비 상태에 있기 때문에 이는 일반적인 스케줄링 결정이며 어느 쪽으로든 진행될 수 있습니다. 즉, 스케줄러는 다음에 실행할 상위 또는 하위 프로세스를 합법적으로 선택할 수 있습니다.

둘째, 프로세스가 종료되면 일정 결정이 이루어져야 합니다. 프로세스가 더 이상 존재하지 않기 때문에 더 이상 실행할 수 없으므로 준비된 프로세스 집합에서 다른 프로세스를 선택해야 합니다. 준비된 프로세스가 없으면 일반적으로 시스템에서 제공하는 유휴 프로세스가 실행됩니다.

셋째, 프로세스가 I/O, 세마포어 또는 기타 이유로 차단되면 실행할 다른 프로세스를 선택해야 합니다. 때로는 막힘의 원인이 선택에 영향을 미칠 수도 있습니다. 예를 들어, A가 중요한 프로세스이고 B가 임계 영역을 종료하기를 기다리고 있는 경우 다음에 B를 실행하면 임계 영역을 종료하여 A가 계속될 수 있습니다. 그러나 문제는 스케줄러에 이러한 종속성을 고려하는 데 필요한 정보가 없는 경우가 많다는 것입니다.

넷째, I/O 인터럽트가 발생할 때 스케줄링 결정을 내릴 수 있습니다. 이제 작업이 완료된 I/O 장치에서 인터럽트가 발생하면 I/O를 기다리는 일부 프로세스가 실행 준비가 될 수 있습니다. 새로 준비된 프로세스, 중단 당시 실행 중이던 프로세스 또는 세 번째 프로세스를 실행할지 여부를 결정하는 것은 스케줄러의 몫입니다.

하드웨어 클록이 50Hz나 60Hz 또는 다른 주파수에서 주기적인 인터럽트를 제공하는 경우 모든 클록 인터럽트 또는 모든 k번째 클록 인터럽트에 대해 스케줄링 결정을 내릴 수 있습니다. 스케줄링 알고리즘은 클럭 인터럽트를 처리하는 방법에 따라 두 가지 범주로 나눌 수 있습니다. 비시간적 스케줄링 알고리즘은 실행할 프로세스를 선택한 다음 해당 프로세스가 차단(I/O 또는 다른 프로세스 대기 중)되거나 자동으로 CPU를 해제할 때까지 실행되도록 합니다. 몇 시간 동안 실행되더라도 강제로 일시 중지되지는 않습니다. 실제로 클럭 인터럽트 중에는 스케줄링 결정이 내려지지 않습니다. 클록 인터럽트가 처리된 후 우선 순위가 높은 프로세스가 현재 충족된 시간 초과를 기다리고 있지 않는 한 인터럽트 이전에 실행 중이던 프로세스가 다시 시작됩니다.

이와 대조적으로 선점형 스케줄링 알고리즘은 프로세스를 선택하고 최대 고정된 기간 동안 실행되도록 합니다. 간격이 끝날 때까지 계속 실행 중이면 일시 중지되고 스케줄러는 실행할 다른 프로세스를 선택합니다(사용 가능한 경우). 선점형 스케줄링을 수행하려면 간격이 끝날 때 CPU 제어를 스케줄러에 반환하는 클록 인터럽트가 필요합니다. 시계를 사용할 수 없는 경우 비 임시 예약이 유일한 옵션입니다.

다양한 환경에서는 다양한 스케줄링 알고리즘이 필요합니다. 스케줄러가 최적화해야 하는 것은 모든 시스템에서 동일하지 않습니다. 구별할 가치가 있는 세 가지 환경은 일괄 처리, 대화형 및 실시간입니다.

스케줄링 알고리즘을 설계하기 위해서는 좋은 알고리즘이 무엇을 해야 하는지 이해하는 것이 필요합니다. 일부 목표는 환경(배치, 대화형 또는 실시간)에 따라 달라지지만 일부 목표는 모든 상황에서 바람직합니다. 일부 목표는 다음과 같습니다.

  • 모든 시스템:

공정성: 각 프로세스에 CPU를 공평하게 분배합니다.

  • 정책 시행: 명시된 정책이 시행되는지 확인합니다.

  • 균형: 시스템의 모든 부분을 바쁘게 유지합니다.

  • 일괄 처리 시스템:

처리량: 시간당 작업 수를 최대화합니다.

  • 처리 시간: 제출부터 종료까지의 시간을 최소화합니다.

  • CPU 활용도: CPU를 항상 바쁜 상태로 유지합니다.

  • 대화형 시스템:

응답 시간: 요청에 신속하게 응답합니다.

  • 비율: 사용자 기대에 부응합니다.

  • 실시간 시스템:

기한 준수: 데이터 손실을 방지하세요.

  • 예측 가능성: 멀티미디어 시스템의 품질 저하를 방지합니다.

어떤 상황에서도 공정성은 중요합니다. 비교 가능한 프로세스는 비교 가능한 서비스를 받아야 하며, 하나의 프로세스에 동등한 프로세스보다 훨씬 더 많은 CPU 시간을 제공하는 것은 불공평합니다. 물론, 다양한 클래스의 프로세스가 다르게 처리될 수 있습니다. 공정성과 관련된 것은 시행 시스템의 정책입니다. 로컬 정책이 보안 제어인 경우 프로세스는 30초의 급여 지연을 의미하더라도 언제든지 실행할 수 있으며 스케줄러는 이 정책이 시행되도록 해야 합니다.

또 다른 전반적인 목표는 시스템의 모든 부분을 가능한 한 바쁘게 유지하는 것입니다. CPU와 모든 I/O 장치가 항상 실행될 수 있다면 일부 구성 요소가 유휴 상태일 때보다 초당 더 많은 작업이 수행됩니다. 예를 들어, 일괄 처리 시스템에서 스케줄러는 실행하기 위해 메모리에 들어갈 작업을 제어할 수 있습니다.

먼저 모든 CPU 바인딩 작업을 로드하고 실행한 다음 작업이 완료되면 모든 I/O 바인딩 작업을 로드하고 실행하는 것보다 일부 CPU 바인딩 프로세스와 일부 I/O 바인딩 프로세스를 메모리에 동시에 두는 것이 더 좋습니다. 후자 전략을 사용하는 경우 CPU 바인딩된 프로세스가 실행 중일 때 CPU를 놓고 경쟁하고 디스크는 유휴 상태가 됩니다. 나중에 I/O 바인딩 작업이 들어오면 디스크를 놓고 경쟁하고 CPU는 유휴 상태가 됩니다. 프로세스를 신중하게 혼합하여 전체 시스템을 동시에 실행하는 것이 가장 좋습니다.

다양한 스케줄링 수준에서 시스템의 상태 전이 다이어그램은 다음과 같습니다.

스케줄링 대기열 다이어그램은 다음과 같습니다.

18.4.5.1 스케줄링 기본 사항

프로세스 스케줄링은 운영 체제의 기본 기능입니다. 컴퓨터가 다중 프로그래밍되면 동시에 CPU를 놓고 경쟁하는 여러 프로세스가 있습니다. CPU가 하나만 사용 가능한 경우 다음에 실행할 프로세스를 선택해야 합니다. 이러한 의사 결정 프로세스를 스케줄링이라고 하며, 이러한 선택을 수행하는 운영 체제의 일부를 스케줄러라고 하며, 이러한 선택을 수행하는 데 사용되는 알고리즘을 스케줄링 알고리즘이라고 합니다.

스케줄링 대기열은 프로세스가 시스템에 들어갈 때 배치되는 작업 대기열입니다. 이 큐는 시스템의 모든 프로세스, 주 메모리에 상주하고 실행을 기다릴 준비가 된 프로세스 또는 준비 큐라는 목록에 저장되는 프로세스로 구성됩니다.

이 큐는 일반적으로 목록의 첫 번째 및 마지막 PCB에 대한 포인터를 포함하는 준비 큐 헤더와 준비 큐의 다음 PCB에 대한 포인터 필드를 포함하는 PCB와 함께 연결된 목록으로 저장됩니다. 특정 I/O 장치를 기다리는 프로세스 목록은 장치 큐라는 목록에 보관되며 각 장치에는 자체 장치 큐가 있습니다. 새로운 프로세스는 처음에 준비 대기열에 배치됩니다. 실행을 위해 선택되고 CPU가 제공될 때까지 준비 대기열에서 기다립니다.

0.png)

18.4.5.2 스케줄러

스케줄러는 다음과 같이 설명됩니다.

  • 프로세스는 수명 주기 동안 다양한 일정 대기열 간에 마이그레이션됩니다. 운영 체제는 일정을 잡기 위해 이러한 대기열에서 프로세스를 어떻게든 선택해야 합니다. 이 선택 프로세스는 적절한 스케줄러에 의해 수행됩니다.

  • 배치 시스템에서는 더 많은 프로세스가 제출된 후 즉시 실행됩니다. 따라서 이러한 프로세스는 대용량 저장 장치(예: 디스크)에 스풀링되어 나중에 실행될 수 있도록 저장됩니다.

스케줄러의 유형은 다음과 같습니다.

장기 스케줄러

장기 스케줄러는 디스크에서 프로세스를 선택하고 실행을 위해 메모리에 로드합니다. 이는 다중 프로그래밍의 정도, 즉 다른 스케줄러보다 덜 자주 실행되는 메모리의 프로세스 수를 제어합니다. 다중 프로그래밍 정도가 안정적인 경우 프로세스가 생성되는 평균 속도는 프로세스가 시스템을 떠나는 평균 속도와 같습니다. 따라서 장기 스케줄러는 프로세스가 시스템을 떠날 때만 호출하면 됩니다. 실행 간격이 길기 때문에 실행을 위해 어떤 프로세스를 선택해야 하는지 결정하는 데 더 많은 시간이 걸릴 수 있습니다.

CPU에 있는 대부분의 프로세스는 I/O 집약적이거나 CPU 집약적입니다. I/O 집약적 프로세스(대화형 "C" 프로그램)는 I/O 작업을 수행하는 대신 I/O 작업을 수행하는 데 대부분의 시간을 보내는 프로세스입니다. CPU 집약적인 프로세스는 I/O 작업(복잡한 정렬 프로그램)보다 컴퓨팅에 더 많은 시간을 소비합니다. 장기 스케줄러는 I/O 바인딩 프로세스와 CPU 바인딩 프로세스를 적절히 혼합하여 선택하는 것이 중요합니다.

단기 스케줄러

단기 스케줄러는 실행할 준비가 된 프로세스 중에서 선택하고 그 중 하나에 CPU를 할당합니다. 이 두 스케줄러의 주요 차이점은 실행 빈도입니다. 단기 스케줄러는 CPU에 대한 새 프로세스를 자주 선택해야 하며, 이 프로세스는 100밀리초 내에 적어도 한 번 실행되어야 합니다. 실행 간격이 짧기 때문에 속도가 매우 빨라야 합니다.

중기 스케줄러

일부 운영 체제에서는 중간 스케줄러라는 추가적인 중간 수준의 스케줄링을 도입합니다. 이 스케줄러의 기본 아이디어는 때때로 메모리에서 프로세스를 제거하여 다중 프로그래밍 수준을 줄이는 것이 유리하다는 것입니다. 그런 다음 프로세스는 메모리를 다시 도입할 수 있고 실행은 중단된 부분부터 다시 시작될 수 있습니다. 이를 스와핑이라고 합니다. 프로세스는 나중에 중간 스케줄러에 의해 호출되고 호출됩니다. 프로세스 누락을 개선하기 위해 스와핑이 필요하거나 메모리 요구 사항의 일부 변경으로 인해 사용 가능한 메모리 제한이 초과되어 일부 메모리를 비워야 합니다.

18.4.5.3 스케줄링 대상

일정 목표는 다음과 같습니다.

  • CPU 활용도: CPU를 최대한 사용하도록 유지합니다.

  • 처리량: 단위 시간당 실행을 완료하는 프로세스 수입니다.

  • 처리 시간: 특정 프로세스를 수행하는 데 걸리는 시간입니다.

  • 대기 시간: 프로세스가 준비 큐에서 기다리는 시간입니다.

  • 응답 시간: 요청 제출부터 첫 번째 응답(출력 아님) 생성까지의 시간(시간 공유 환경의 경우)

일정 계획의 전반적인 목표:

  • 공정해요. 어떤 상황에서도 공정성은 중요합니다. 스케줄러는 각 프로세스가 CPU를 공정하게 공유하고 프로세스가 무한정 지연되지 않도록 합니다.

동등하거나 동등한 시간을 주는 것은 공평하지 않다는 점에 유의하시기 바랍니다. 원자력 발전소의 안전 통제와 급여를 생각해 보십시오.

  • 정책 시행. 스케줄러는 시스템 정책이 시행되는지 확인해야 합니다. 예를 들어, 로컬 정책이 안전하려면 급여 처리가 지연되더라도 보안 제어 처리가 항상 실행될 수 있어야 합니다.

  • 효율성. 가능하다면 스케줄러는 시스템(특히 CPU)을 몇 퍼센트의 시간 동안 계속 사용하도록 해야 합니다. CPU와 모든 입/출력 장치가 항상 실행될 수 있다면 일부 구성 요소가 유휴 상태일 때보다 초당 더 많은 작업을 수행할 수 있습니다.

  • 응답 시간. 스케줄러는 대화형 사용자의 응답 시간을 최소화해야 합니다.

  • 환생. 스케줄러는 배치 사용자가 출력을 기다려야 하는 시간을 최소화해야 합니다.

-처리량. 스케줄러는 단위 시간당 처리되는 작업 수를 최대화해야 합니다.

잠시 생각해 보면 이러한 목표 중 일부가 모순된다는 것을 알 수 있습니다. 한 유형의 작업을 선호하는 스케줄링 알고리즘은 다른 유형의 작업에 해를 끼칠 수 있음을 알 수 있습니다. 결국 사용 가능한 CPU 시간은 제한되어 있습니다.

스케줄링 알고리즘은 클럭 인터럽트를 처리하는 방법에 따라 두 가지 범주로 나눌 수 있습니다.

  • 비선점형 스케줄링. 프로세스에 CPU가 부여되면 프로세스에서 CPU를 제거할 수 없으며 스케줄러는 비선점형입니다. 비선점형 스케줄링의 특징은 다음과 같습니다.

비선점형 시스템에서는 짧은 작업이 긴 작업에 의해 대기되지만 전체 프로세스의 전체 처리는 공평합니다.

  • 비선점형 시스템에서는 들어오는 우선순위가 높은 작업이 대기 중인 작업을 대체할 수 없기 때문에 응답 시간이 더 예측 가능합니다.

  • 비선점형 스케줄링에서 스케줄러는 두 가지 상황에서 작업을 실행합니다: 1. 프로세스가 실행 상태에서 대기 상태로 전환될 때; 2. 프로세스가 종료되는 경우.

  • 선제적 스케줄링. 프로세스가 제공되면 CPU를 제거할 수 있는 경우 스케줄링 절차가 우선합니다. 논리적으로 실행 가능한 프로세스를 일시적으로 중단하도록 허용하는 전략을 선점형 스케줄링이라고 하며, 이는 "실행 후 완료" 접근 방식과 반대입니다.

18.4.5.4 CPU 스케줄링 알고리즘

CPU 스케줄링은 준비 큐에 있는 어떤 프로세스가 CPU에 할당될 것인지 결정하는 문제를 다룹니다. 다음은 우리가 살펴볼 몇 가지 스케줄링 알고리즘입니다.

선착순(FCFS)

FCFS는 가장 간단한 CPU 스케줄링 알고리즘입니다. CPU를 먼저 요청한 프로세스가 CPU에 먼저 할당된 프로세스입니다. FIFO 큐로 쉽게 관리할 수 있으며, 프로세스가 준비 큐에 들어가면 해당 PCB가 큐 뒤쪽에 연결됩니다. 그러나 FCFS의 평균 대기 시간은 매우 높습니다. 다음 상황을 고려하십시오.

프로세스 CPU 시간

P1 3

P2 4

P3 2

P4 4

순서가 P1, P2, P3, P4인 경우 FCFS 알고리즘을 사용하여 평균 대기 시간과 평균 처리 시간을 계산합니다. 해결 방법: 프로세스가 P1, P2, P3, P4 순서로 도착하면 FCFS에 따라 간트 차트는 다음과 같습니다.

다양한 시간은 아래에 설명되어 있습니다.

  • 프로세스 대기 시간: P1 = 0, P2 = 3, P3 = 8, P4 = 10.

  • 프로세스 처리 시간(Turnaround) 시간: P1 = 0 + 3 = 3, P2 = 3 + 5 = 8, P3 = 8 + 2 = 10, P4 = 10 + 4 =14.

  • 평균 대기 시간: (0 + 3 + 8 + 10) / 4 = 21 / 4 = 5.25.

  • 평균 처리 시간: (3 + 8 + 10 + 14)/4 = 35 / 4 = 8.75.

FCFS 알고리즘은 비선점형입니다. 즉, CPU가 프로세스에 할당되면 프로세스는 CPU가 해제될 때까지 I/O를 종료하거나 요청하여 CPU를 보유합니다.

SJF(가장 짧은 작업 우선)

이 알고리즘의 또 다른 이름은 SPN(Shortest Process Next)입니다. 이 알고리즘은 CPU를 사용할 수 있는 경우 모든 프로세스와 연결됩니다. 이러한 종류의 스케줄링을 최단 다음 CPU 버스트(버스트)라고도 합니다. 전체 길이가 아닌 프로세스의 다음 CPU 버스트 길이를 확인하여 스케줄링을 수행하기 때문입니다. 다음 상황을 고려하십시오.

프로세스 CPU 시간

P1 3

P2 5

P3 2

P4 4

해결책: SJF에 따르면 간트 차트는 다음과 같습니다.

다양한 시간은 아래에 설명되어 있습니다.

  • 프로세스 대기 시간: P1 = 0, P2 = 2, P3 = 5, P4 = 9.

  • 프로세스 처리 시간: P3 = 0 + 2 = 2, P1 = 2 + 3 = 5, P4 = 5 + 4 = 9, P2 = 9 + 5 =14.

  • 평균 대기 시간: (0 + 2 + 5 + 9) / 4 = 16 / 4 = 4.

  • 평균 처리 시간: (2 + 5 + 9 + 14)/4 = 30 / 4 = 7.5.

SJF 알고리즘은 선점형 또는 비선점형 알고리즘일 수 있습니다. 선점형 SJF는 최단 잔여 시간 우선이라고도 불린다. 다음 예를 고려하십시오.

프로세스 도착 시간 CPU 시간

P1 0 8

P2 1 4

P3 2 9

P4 3 5

이 상황에 대한 Gantt 차트는 다음과 같습니다.

프로세스 대기 시간: P1 = 10 - 1 = 9, P2 = 1 - 1 = 0, P3 = 17 - 2 = 15, P4 = 5 - 3 = 2.

평균 대기 시간: (9 + 0 + 15 + 2) / 4 = 26 / 4 = 6.5.

우선순위 예약

이 스케줄링에서는 각 프로세스에는 우선순위 번호(정수)가 있으며, 우선순위가 가장 높은(최소 정수, 최고 우선순위) 프로세스에 CPU가 할당되는데, 이는 선점형과 비선점형으로 나눌 수 있습니다. FCFS 모드에서는 우선 순위가 같은 프로세스가 정렬됩니다. SJF는 우선순위 스케줄링으로, 우선순위는 다음 CPU 버스트의 예상 시간입니다.

기아 문제가 있습니다. 우선 순위가 낮은 프로세스는 실행되지 않을 수 있으며 솔루션은 노후화됩니다. 프로세스의 우선 순위는 시간이 지남에 따라 증가합니다.

우선순위는 내부적으로 또는 외부적으로 정의될 수 있습니다. 내부 우선순위의 예로는 시간 제약, 메모리 요구 사항, 파일 요구 사항(예: 열린 파일 수), CPU 및 I/O 요구 사항이 있습니다. 외부적으로 정의된 우선순위는 프로세스의 중요성, 컴퓨터 사용에 대해 지불한 금액이나 유형, 작업을 후원하는 부서, 정책 등 운영 체제 외부의 기준에 의해 설정됩니다.

우선순위 큐.

다음 예를 고려하십시오.

프로세스 도착 시간 CPU 시간

P1 10 3

P2 1 1

P3 2 3

P4 1 4

P5 5 2

우선순위 일정에 따라 간트 차트는 다음과 같습니다.

프로세스 대기 시간: P1 = 6, P2 = 0, P3 = 16, P4 = 18, P5 = 1.

평균 대기 시간: (0 + 1 + 6 + 16 + 18) / 5 = 41 / 5 = 8.2.

라운드 로빈

이 알고리즘은 시분할 시스템 설계에만 사용되며 프로세스 간 전환이 가능한 선점 조건이 있는 FCFS 스케줄링과 유사합니다. 프로세스 간 전환에는 퀀텀 타임(Quantum Time) 또는 타임 슬라이스(Time Slice)라는 작은 시간 단위가 사용되며, 폴링 시 평균 대기 시간은 매우 깁니다. 다음 예를 고려하십시오.

프로세스 CPU 시간

P1 3

P2 5

P3 2

P4 4

시간 분할 = 1ms인 경우 간트 차트는 다음과 같습니다.

프로세스 대기 시간:

  • P1 = 0 + (4 – 1) + (8 – 5) = 0 + 3 + 3 = 6

  • P2 = 1 + (5 – 2) + (9 – 6) + (11 – 10) + (13 – 12) = 1 + 3 + 3 + 1 + 1 = 9

  • P3 = 2 + (6 – 3) = 2 + 3 = 5

  • P4 = 3 + (7 – 4) + (10 – 8) + (12 – 11) = 3 + 3 + 2 + 1 = 9

평균 대기 시간: (6 + 9 + 5 + 9) / 4 = 7.2

최소 남은 시간(SRT)

SRT는 SJF의 선점형 대응이며 시간 공유 환경에 유용합니다. SRT 스케줄링에서는 새로 도착한 프로세스를 포함하여 예상 실행 시간이 가장 짧은 프로세스가 다음에 실행됩니다. SJF 방식에서는 작업이 실행되기 시작하면 완료될 때까지 실행되며 실행 중인 프로세스는 예상 실행 시간이 가장 짧은 새로 도착한 프로세스에 의해 선점될 수 있습니다. SRT는 SJF보다 오버헤드가 높으며 실행 프로세스의 경과 시간을 추적해야 하며 가끔 선점을 처리해야 합니다. 이 시나리오에서는 작은 프로세스가 도착하자마자 거의 즉시 실행되지만 작업이 길수록 대기 시간도 길어집니다.

가장 짧은 프로세스 다음

가장 짧은 작업은 애초에 배치 시스템에 대해 항상 가장 작은 평균 응답 시간을 생성하므로 대화형 프로세스에도 사용할 수 있다면 더 좋을 것입니다. 이는 어느 정도 가능합니다. 대화형 프로세스는 일반적으로 명령 대기, 명령 실행, 명령 대기, 명령 실행 등의 패턴을 따릅니다. 각 명령의 실행을 별도의 "작업"으로 처리하면 현재 실행 가능한 프로세스 중에서 가장 짧은 프로세스를 찾는다면 가장 짧은 프로세스를 먼저 실행하여 전체 응답 시간을 최소화할 수 있습니다.

예약 보장

스케줄링에 대한 완전히 다른 접근 방식은 사용자에게 성능에 대해 실제 약속을 한 다음 해당 약속을 이행하는 것입니다. 현실적이고 쉽게 달성할 수 있는 약속은 다음과 같습니다. 작업하는 동안 n명의 사용자가 로그인하면 CPU 성능의 약 1/n을 얻게 됩니다. 마찬가지로, n개의 프로세스를 실행하는 단일 사용자 시스템에서는 모든 것이 동일할 때 각 프로세스가 1/n CPU 주기를 가져야 하는 것이 공평해 보입니다.

이 약속을 이행하려면 시스템은 각 프로세스가 생성된 이후 얼마나 많은 CPU를 사용했는지 추적해야 합니다. 그런 다음 각 프로세스가 사용할 수 있는 CPU 양을 생성 이후 시간을 n으로 나눈 값으로 계산합니다. 각 프로세스가 실제로 소유하고 있는 CPU 시간의 양도 알려져 있으므로, 해당 프로세스가 사용할 수 있는 CPU 시간에 소비된 실제 CPU 시간의 비율을 계산하는 것은 매우 간단합니다. 비율이 0.5라는 것은 프로세스에 필요한 것의 절반만 포함되어 있음을 의미하고, 비율이 2.0은 프로세스에 필요한 것의 두 배만 포함되어 있음을 의미합니다. 그런 다음 알고리즘은 비율이 가장 가까운 경쟁자의 비율을 초과할 때까지 가장 낮은 비율로 프로세스를 실행합니다. 그런 다음 다음 실행을 선택합니다.

복권 일정

사용자에게 약속한 다음 이를 이행하는 것은 좋은 생각이지만 실행하기는 어렵습니다. 그러나 더 간단한 구현으로 유사한 예측 가능한 결과를 제공하는 다른 알고리즘을 사용할 수 있습니다. 이것을 복권 예약이라고 합니다.

기본 아이디어는 CPU 시간과 같은 다양한 시스템 리소스에 대한 프로세스 복권을 제공하는 것입니다. 일정 결정을 내려야 할 때마다 복권이 무작위로 선택되고 해당 복권을 보유한 프로세스가 리소스를 얻습니다. CPU 스케줄링에 적용하면 시스템은 초당 50회의 추첨을 보유할 수 있으며 각 승자는 20밀리초의 CPU 시간을 상금으로 받습니다.

공정 공유 일정

지금까지 우리는 각 프로세스가 소유자에 관계없이 자체적으로 일정을 잡는다고 가정했습니다. 따라서 사용자 1이 9개의 프로세스를 시작하고 사용자 2가 라운드 로빈 또는 동일한 우선순위로 하나의 프로세스를 시작하면 사용자 1은 CPU의 90%를 가져오고 사용자 2는 10%만 가져옵니다.

이를 방지하기 위해 일부 시스템에서는 프로세스를 예약하기 전에 프로세스를 소유한 사용자를 고려합니다. 이 모델에서는 각 사용자에게 CPU의 일부가 할당되고 스케줄러는 강제 방식으로 프로세스를 선택합니다. 따라서 두 명의 사용자에게 각각 CPU의 50%를 약속하면 프로세스 수에 관계없이 CPU의 50%를 얻게 됩니다.

예를 들어, 두 명의 사용자가 각각 CPU의 50%를 할당하는 시스템을 생각해 보세요. 사용자 1에는 4개의 프로세스(A, B, C, D)가 있고 사용자 2에는 하나의 프로세스(E)만 있습니다. 라운드 로빈 스케줄링을 사용하는 경우 모든 제약 조건을 만족하는 가능한 스케줄링 순서는 다음과 같습니다.

cpp
A E B E C E D E A E B E C E E D E ...

반면, 사용자 1이 사용자 2의 CPU 시간의 두 배를 받을 자격이 있는 경우 다음과 같은 결과를 얻을 수 있습니다.

cpp
A B E C D E A B E C D E ...

물론 공정성의 개념이 무엇인지에 따라 활용될 수 있는 다른 가능성도 많이 있습니다.

여러 대기열

다중 레벨 대기열 스케줄링 알고리즘은 준비된 대기열을 여러 개의 별도 대기열로 나눕니다. 예를 들어, 다중 수준 대기열 예약에서는 프로세스가 대기열에 영구적으로 할당됩니다. 이러한 프로세스는 메모리 크기, 프로세스 우선순위, 프로세스 유형 등 프로세스의 특정 속성을 기반으로 다른 프로세스에 영구적으로 할당됩니다. 알고리즘은 채워진 대기열에서 우선 순위가 가장 높은 프로세스를 선택한 다음 해당 프로세스를 실행합니다.

다단계 피드백 대기열

다중 레벨 피드백 큐 스케줄링 알고리즘을 사용하면 많은 준비된 큐를 사용하고 각 큐에 서로 다른 우선순위를 연결하여 프로세스가 큐 간에 이동할 수 있습니다. 알고리즘은 점유된 대기열에서 우선순위가 가장 높은 프로세스를 선택하고 선점형 또는 비선점형 모드에서 프로세스를 실행합니다. 프로세스가 CPU 시간을 너무 많이 사용하면 우선 순위가 낮은 대기열로 이동합니다. 마찬가지로, 낮은 우선순위 큐에서 너무 오래 기다리는 프로세스는 더 높은 우선순위 큐로 이동되거나 가장 높은 우선순위 큐로 이동될 수 있습니다. 이러한 형태의 노화는 기아로부터 보호됩니다. 예:

  • 준비 큐에 들어간 프로세스는 큐 0에 배치됩니다.

  • 8ms 이내에 완료되지 않으면 대기열 1의 끝으로 이동됩니다.

  • 완료되지 않으면 선점되어 대기열 2에 배치됩니다.

  • 대기열 2의 프로세스는 대기열 0과 대기열 1이 비어 있고 대기열 2가 FCFS 기본 대기열에서 실행되는 경우에만 FCFS 기반에서 실행됩니다.

3개의 대기열: Q0–RR, 8밀리초; Q1–RR, 16밀리초; 2분기-FCFS.

일반적으로 다중 레벨 피드백 큐 스케줄러에 의해 정의되는 매개변수는 큐 수, 각 큐에 대한 스케줄링 알고리즘, 프로세스를 더 높은 우선순위 큐로 승격할 시기를 결정하는 방법, 프로세스를 더 낮은 우선순위 큐로 강등할 시기를 결정하는 방법, 서비스가 필요할 때 프로세스가 들어갈 큐를 결정하는 방법입니다.

18.4.5.5 일정 요약

실제 운영 환경에서 프로세스 스케줄링은 CPU, 코어 수, IO 등과 같은 요소의 영향을 고려한 다음 다양한 스케줄링 알고리즘을 사용하여 데이터를 계산하여 상대적으로 객관적이고 참조할 가치가 있는 스케줄링 데이터를 얻어야 합니다. 평가 다이어그램은 다음과 같습니다.

다양한 스케줄링 전략의 특징:

스케줄링 전략의 타이밍 비교 다이어그램:

스케줄링 전략의 종합적인 비교:

위에서 언급한 스케줄링 알고리즘 외에도 실시간 운영체제에 적합한 Deadline Scheduling, Rate Monotonic Scheduling 등과 같은 다른 스케줄링 전략도 실제로 많이 있습니다.

또한, 우선순위 역전(Priority Inversion) 현상은 모든 우선순위 기반 선점형 스케줄링 방식에서 발생할 수 있는 현상이지만 특히 실시간 스케줄링 환경에서 관련이 있다. 우선순위 역전의 가장 유명한 예는 1997년 7월 4일 화성에 착륙하여 방대한 양의 데이터를 수집하여 지구로 다시 전송하기 시작한 탐사선인 Mars Pathfinder 임무와 관련이 있습니다. 그러나 임무가 시작된 지 며칠 후 착륙선의 소프트웨어가 전체 시스템 재설정을 거치기 시작했고 매번 데이터 손실이 발생했습니다. Pathfinder를 구축하는 JPL 팀의 광범위한 노력 끝에 문제는 우선 순위 반전으로 추적되었습니다.

모든 우선순위 스케줄링 체계에서 시스템은 항상 가장 높은 우선순위로 작업을 실행해야 합니다. 우선순위 역전은 시스템 내의 조건으로 인해 우선순위가 높은 작업이 우선순위가 낮은 작업을 기다리게 될 때 발생합니다. 우선 순위 역전의 간단한 예는 우선 순위가 낮은 작업이 리소스(예: 장치 또는 바이너리 세마포어)를 잠그고 우선 순위가 높은 작업이 동일한 리소스를 잠그려고 시도하는 경우 발생할 수 있습니다. 리소스를 사용할 수 있을 때까지 우선순위가 높은 작업은 차단됩니다. 우선 순위가 낮은 작업이 빠르게 완료되고 리소스가 해제되면 우선 순위가 높은 작업이 빠르게 재개될 수 있으며 실시간 제약 조건을 위반하지 않을 수 있습니다.

더 심각한 상황은 언바운드 우선순위 역전(Unbound Priority Inversion)이라고 하며, 우선순위 역전 기간은 공유 리소스를 처리하는 데 필요한 시간뿐 아니라 관련되지 않은 다른 작업의 예측할 수 없는 작동에 따라 달라집니다. Pathfinder 소프트웨어에서 경험하는 우선순위 반전은 무한하며 완벽한 예입니다.

18.4.6 프로세스 속성

Windows 프로세스의 공통 속성은 작업 관리자에서 볼 수 있으며 해당 세부 정보는 아래에 설명되어 있습니다.

  • 이름. 일반적으로 프로세스의 기반이 되는 실행 파일의 이름이지만 프로세스의 고유 식별자는 아닙니다. 시스템, 보안 시스템, 레지스트리, 메모리 압축, 시스템 유휴 프로세스 및 시스템 인터럽트를 포함하여 일부 프로세스에는 실행 가능한 이름이 전혀 없는 것으로 보입니다.

시스템 인터럽트는 실제로 프로세스가 아니지만 커널이 하드웨어 인터럽트 및 지연된 프로시저 호출을 서비스하는 데 걸리는 시간을 측정한 것입니다.

  • 시스템 유휴 프로세스는 실제 프로세스가 아닙니다. 프로세스 ID(PID)는 항상 0입니다. 이는 단지 Windows 유휴 시간, 즉 CPU가 아무 작업도 수행하지 않는 시간의 비율을 설명합니다.

  • 시스템 프로세스는 실제 프로세스이며 기술적으로 최소화된 프로세스이며 항상 PID가 4입니다. 이는 커널 및 커널 드라이버에서 사용하는 메모리, 열린 핸들, 스레드 등 커널 공간에서 발생하는 모든 것을 나타냅니다.

  • 보안 시스템 프로세스는 가상화 기반 보안이 활성화된 상태로 부팅되는 Windows 10 및 Server 2016(및 이후 버전) 시스템에서만 사용할 수 있습니다. 이는 보안 코어에서 발생하는 모든 것을 나타냅니다.

  • 레지스트리 프로세스는 이전 버전처럼 페이징 풀을 사용하는 대신 레지스트리 관리를 위한 "작업 공간" 역할을 하는 Windows 10 버전 1803(RS4)에서 사용 가능한 최소화된 프로세스입니다.

  • 메모리 압축 프로세스는 Windows 10 버전 1607에서 사용할 수 있는 최소화된 프로세스이며 해당 주소 공간에 압축된 메모리를 보유합니다. 메모리 압축은 특히 휴대폰 및 IoT 장치와 같이 리소스가 제한된 장치의 경우 실제 메모리(RAM)를 보존하기 위해 Windows 10에 추가된 기능입니다. 혼란스럽게도 작업 관리자에는 이 프로세스가 표시되지 않지만 프로세스 탐색기에는 올바르게 표시됩니다.

  • PID. 프로세스의 고유 ID는 4의 배수이며, 가장 낮은 유효한 PID 값은 4(시스템 프로세스에 속함)입니다. 프로세스가 종료되면 프로세스 ID가 재사용되므로 새 프로세스가 표시됩니다. 프로세스에 고유 식별자가 필요한 경우 PID와 프로세스 시작 시간의 조합은 실제로 특정 시스템에서 고유합니다.

  • 상태. 상태는 실행 중, 일시 중단됨, 응답 없음의 세 가지 값 중 하나를 가질 수 있으며 해당 값은 프로세스 유형에 따라 요약됩니다.

프로세스 유형 런타임 상황 중단 상황 응답 없음

GUI 프로세스(비UWP) GUI 스레드가 응답하는 경우 프로세스의 모든 스레드가 중단됩니다. GUI 스레드가 최소 5초 동안 메시지 대기열을 확인하지 않았습니다.

CUI 프로세스(비UWP) 하나 이상의 스레드가 일시 중단되지 않았습니다. 프로세스의 모든 스레드가 중단됩니다. 사용 여부

UWP 프로세스 배경에 배경에 GUI 스레드가 최소 5초 동안 메시지 대기열을 확인하지 않았습니다.

일반적인 상태 전환은 아래 그림에 나와 있습니다.

  • 사용자 이름. 사용자 이름은 프로세스가 실행 중인 사용자를 나타냅니다. 토큰 개체는 프로세스가 저장되는 사용자의 보안 컨텍스트를 기반으로 프로세스(기본 토큰이라고 함)에 연결됩니다. 보안 컨텍스트에는 사용자가 속한 그룹, 권한 등과 같은 정보가 포함됩니다. 프로세스는 로컬 시스템(작업 관리자에서 시스템으로 표시됨), 네트워크 서비스 및 로컬 서비스와 같은 특정 기본 제공 사용자에서 실행될 수 있습니다. 이러한 사용자 계정은 일반적으로 서비스를 실행하는 데 사용됩니다.

  • 세션 ID. 프로세스가 실행되는 세션 번호, 세션 0은 시스템 프로세스 및 서비스에 사용되고 세션 1 이상은 대화형 로그인에 사용됩니다.

  • CPU. 프로세스의 CPU 소비 비율을 표시합니다. 정수만 표시됩니다. 정확성을 높이려면 프로세스 탐색기를 사용하세요.

  • 메모리. 메모리 관련 열은 조금 더 까다롭습니다. 작업 관리자에 표시되는 기본 열은 메모리(활성 개인 작업 세트) 또는 메모리(개인 작업 세트, 이전 버전)입니다. 작업 세트라는 용어는 RAM(물리적 메모리)을 나타내며 개인 작업 세트는 프로세스에서 사용하는 RAM이며 다른 프로세스와 공유되지 않습니다. 공유 메모리의 가장 일반적인 예는 DLL 코드입니다. 활성 개인 작업 세트는 개인 작업 세트와 동일하지만 현재 일시 중단된 UWP 프로세스에 대해 0으로 설정됩니다. 위의 두 카운터는 프로세스에서 사용하는 메모리 양을 잘 나타냅니까? 안타깝게도 그렇지 않습니다. 이러한 명령어는 전용 RAM을 사용하지만 현재 페이지 아웃되고 있는 메모리는 어떻습니까? 프로세스의 메모리 사용량을 이해하는 데 가장 적합한 열인 Commit Size라는 또 다른 열이 있습니다. 작업 관리자는 기본적으로 이 열을 표시하지 않습니다.

  • 기본 우선순위. 기본 우선순위 열(공식적으로 우선순위 클래스라고 함)은 이 프로세스에서 실행되는 스레드에 대한 기본 예약 우선순위를 제공하는 6개 값 중 하나를 표시합니다. 우선순위와 연관된 가능한 값은 다음과 같습니다.

낮음(유휴) = 4

  • 정상 이하 = 6

  • 보통 = 8. 가장 일반적인(기본값) 우선순위 범주는 보통(8)입니다.

  • 정상 이상 = 10

  • 높음 = 13

  • 실시간 = 24

  • 핸들. 특정 프로세스에서 열려 있는 커널 개체에 대한 핸들 수를 표시합니다.

  • . 스레드 열에는 각 프로세스의 스레드 수가 표시됩니다. 스레드가 없는 프로세스는 쓸모가 없으므로 일반적으로 최소한 하나는 있어야 합니다. 그러나 일부 프로세스에는 스레드가 없는 것으로 나타납니다. 특히 보안 코어는 실제로 예약을 위해 일반 코어를 사용하기 때문에 보안 시스템에는 스레드가 없는 것처럼 보입니다. 시스템 인터럽트 의사 프로세스는 전혀 프로세스가 아니므로 스레드를 가질 수 없습니다. 마지막으로 시스템 유휴 프로세스도 스레드를 소유하지 않습니다. 이 프로세스에 대해 표시되는 스레드 수는 시스템의 논리 프로세서 수입니다.

프로세스의 보다 자세한 속성 테이블은 다음과 같습니다.

가상 메모리의 사용자 프로세스:

18.4.7 프로세스 운영

프로세스 생성과 관련된 주요 부분은 아래 그림과 같습니다.

  1. 이미지 파일 열기

커널은 이미지(실행 파일) 파일을 열고 해당 파일이 PE(Portable Executable)에 대한 올바른 형식인지 확인합니다. 파일 확장자는 중요하지 않으며 실제 내용이 중요합니다. 다양한 헤더 파일이 유효하다고 가정하면 커널은 새로운 프로세스 커널 개체와 스레드 커널 개체를 생성합니다. 왜냐하면 일반 프로세스는 스레드에 의해 생성되고 결국 기본 진입점을 실행해야 하기 때문입니다.

  1. 커널 프로세스 객체 생성 및 초기화

이 시점에서 커널은 이미지를 NtDll.Dll뿐만 아니라 새 프로세스의 주소 공간에도 매핑합니다. NtDll은 프로세스 생성의 마지막 단계에서 매우 중요한 역할을 담당하고 시스템 호출이 호출되는 마지막 단계이기 때문에 모든 프로세스(최소 및 마이크로 프로세스 제외)에 매핑됩니다. 생성 프로세스가 수행 중인 마지막 주요 단계는 새 프로세스와 스레드가 생성되었음을 Windows 하위 시스템 프로세스(Csrss.exe)에 알리는 것입니다. (Csrss는 Windows 하위 시스템 프로세스의 특정 측면을 관리하는 커널의 보조자로 생각할 수 있습니다.)

  1. 커널 스레드 객체 생성 및 초기화

이때 커널 입장에서는 프로세스가 성공적으로 생성되었으므로 호출자(보통 CreateProcess)가 호출한 프로세스 생성 함수는 성공을 반환한다. 그러나 새 프로세스는 아직 초기 코드를 실행할 준비가 되지 않았습니다. 프로세스 초기화의 두 번째 부분은 새 프로세스의 컨텍스트에서 새로 생성된 스레드에 의해 수행되어야 합니다.

일부 개발자는 새 프로세스에서 가장 먼저 실행되는 것이 실행 파일의 주요 기능이라고 믿습니다. 그러나 현재 프로세스에 다른 운영 체제 수준 코드가 없기 때문에 실제 주요 기능, 특히 NtDll이 실행되기 전에 수행해야 할 작업이 더 많습니다. 이 시점에서 NtDll에는 다음과 같은 여러 가지 책임이 있습니다.

  • 먼저, 프로세스 환경 블록(PEB)이라는 프로세스에 대한 사용자 모드 관리 개체와 스레드 환경 블록(TEB)이라는 첫 번째 스레드에 대한 사용자 모드 제어 개체를 생성합니다. 이러한 구조는 부분적으로 문서화되어 있으며(<winternl.h>에) 개발자가 직접 사용하면 안 됩니다. 즉, 특히 달성하기 어려운 것을 달성하려고 할 때 이 구조가 유용한 상황이 있습니다.

  • 그런 다음 기본 프로세스 힙 생성, 기본 프로세스 스레드 풀 생성 및 초기화 등을 포함한 다른 초기화를 수행합니다.

  • 진입점이 실행을 시작하기 전 마지막 주요 부분은 종종 로더라고 불리는 필수 DLL을 로드하는 것입니다. 로더는 실행 파일이 의존하는 모든 라이브러리를 포함하는 실행 파일의 가져오기 섹션을 살펴봅니다. 여기에는 일반적으로 kernel32.dll, user32.dll, gdi32.dll 및 advapi32.dll과 같은 Windows 하위 시스템 dll이 포함됩니다.

실제로 개발자는 각각 해당 C/C++ 런타임 함수를 포함하는 네 가지 주요 함수를 작성할 수 있습니다. 다음 표에는 이러한 이름과 사용 시기가 요약되어 있습니다.

개발자의 메인 C/C++ 런타임 시작점 장면

메인 메인CRT스타트업 ASCII 문자를 사용하는 콘솔 애플리케이션

웨메인 wmainCRTStartup 유니코드 문자를 사용하는 콘솔 애플리케이션

윈메인 WinMainCRTStartup ASCII 문자를 사용하는 GUI 애플리케이션

wWinMain wWinMainCRTStartup 유니코드 문자를 사용하는 GUI 애플리케이션

대부분의 프로세스는 시스템이 종료되기 전 특정 시점에 종료되며 프로세스를 종료하거나 종료하는 방법에는 여러 가지가 있습니다. 한 가지 기억해야 할 점은 프로세스가 어떻게 종료되든 커널은 프로세스에 개인적인 것이 아무것도 없도록 보장한다는 것입니다. 모든 개인(비공유) 메모리가 해제되고 프로세스 핸들 테이블의 모든 핸들이 닫힙니다. 다음 조건 중 하나라도 충족되면 프로세스가 종료됩니다.

  1. 프로세스의 모든 스레드가 종료되거나 종료됩니다.

  2. 프로세스의 모든 스레드는 ExitProcess를 호출합니다.

  3. TerminateProcess를 사용하여 프로세스를 종료합니다(일반적으로 외부에서 수행되지만 처리되지 않은 예외로 인해 발생할 수도 있음).

Windows 애플리케이션을 작성하는 개발자는 일반적으로 어느 시점에서 메인 기능을 실행하는 스레드가 "특별"한 스레드(주로 메인 스레드라고 함)라는 사실을 발견합니다. 주 함수가 반환될 때마다 프로세스가 종료되는 것을 볼 수 있습니다. 위의 프로세스 종료 이유에 나열되지 않은 시나리오인 것 같습니다. 그러나 실제로는 위의 사례 2입니다. C/C++ 런타임 라이브러리는 main/WinMain을 호출한 다음 전역 C++ 소멸자 호출, C 런타임 정리 등 필요한 정리를 수행하고 마지막으로 ExitProcess를 호출하여 프로세스를 종료합니다.

커널의 관점에서 볼 때 프로세스의 모든 스레드는 동일하며 기본 스레드가 없습니다. 스레드가 없는 프로세스는 거의 쓸모가 없기 때문에 커널의 모든 스레드가 종료/종료되면 커널은 프로세스를 파괴합니다. 실제로 이러한 상황은 기본 프로세스(NtDll.dll에만 의존하고 C/C++ 런타임은 사용하지 않는 실행 파일)에서만 달성될 수 있습니다. 즉, 일반적인 Windows 프로그래밍에서는 발생할 가능성이 거의 없습니다.

18.4.8 프로세스 보충

18.4.8.1 다중 프로그래밍 모델링

다중 프로그래밍을 사용하면 CPU 활용도가 향상될 수 있습니다. 대략적으로 말하면, 평균 프로세스가 메모리에 있는 시간의 20%만 계산한다면 CPU는 5개의 프로세스가 동시에 메모리에 있을 때 항상 사용 중이어야 합니다. 그러나 이 모델은 5개 프로세스 모두가 동시에 I/O를 기다리지 않는다고 가정하기 때문에 비현실적으로 낙관적입니다.

더 나은 모델은 확률적 관점에서 CPU 사용량을 살펴보는 것입니다. 프로세스가 I/O가 완료될 때까지 기다리는 시간의 일부를 소비한다고 가정합니다. 메모리에 동시에 n개의 프로세스가 있는 경우 n개의 프로세스가 모두 I/O를 기다리고 있을 확률은 p^n입니다(이 경우 CPU는 유휴 상태입니다). CPU 사용률은 다음 공식으로 계산됩니다.

CPU 사용률=1pn

아래 그래프는 다중 프로그래밍 정도라고 알려진 n의 함수로 CPU 사용률을 보여줍니다.

CPU 사용률은 메모리의 프로세스 수에 따라 달라집니다.

그림을 보면 프로세스가 I/O를 기다리는 시간의 80%를 소비한다면 CPU 낭비를 10% 미만으로 유지하려면 동시에 메모리에 최소 10개의 프로세스가 있어야 한다는 것이 분명합니다. 사용자가 터미널에 무언가를 입력하거나 아이콘을 클릭하기를 기다리는 대화형 프로세스가 I/O 대기 상태에 있다는 것을 알게 되면 I/O 대기 시간이 80%를 넘는 경우가 드물지 않다는 점을 분명히 해야 합니다. 그러나 서버에서도 많은 디스크 I/O를 수행하는 프로세스는 일반적으로 이 비율 이상을 갖습니다.

정확성을 위해 방금 설명한 확률 모델은 단지 근사치일 뿐이라는 점에 유의해야 합니다. 이는 모든 n개의 프로세스가 독립적이라고 암시적으로 가정합니다. 즉, 메모리에 5개의 프로세스가 있는 시스템에는 3개의 프로세스가 실행되고 2개의 프로세스가 대기할 수 있습니다. 그러나 단일 CPU에서는 세 개의 프로세스를 동시에 실행할 수 없으므로 CPU가 바쁠 때 준비된 프로세스는 기다려야 하므로 프로세스는 독립적이지 않습니다. 큐잉 이론을 사용하면 더 정확한 모델을 구축할 수 있지만 우리가 수행한 다중 프로그래밍은 프로세스가 유휴 상태일 때 CPU를 사용하도록 하는 것이었고, 물론 위 그래프의 실제 곡선이 그래프에 표시된 것과 약간 다르지만 여전히 작동합니다.

위 그림의 모델은 개념상 단순하지만 CPU 성능을 대략적이지만 구체적으로 예측하는 데 여전히 사용할 수 있습니다. 예를 들어, 컴퓨터의 메모리가 8GB라고 가정하면 운영 체제와 테이블은 2GB를 차지하고 각 사용자 프로그램도 2GB를 차지합니다. 이러한 크기를 사용하면 세 개의 사용자 프로그램이 동시에 메모리에 있을 수 있으며 평균 I/O 대기 시간이 80%일 때 CPU 사용률(운영 체제 오버헤드 무시)은 10.83 또는 약 49%입니다. 8GB의 메모리를 추가하면 시스템이 3방향 다중 프로그래밍에서 7방향 다중 프로그래밍으로 전환되어 CPU 사용률이 79%까지 증가합니다. 즉, 8GB를 추가하면 처리량이 30% 증가합니다.

8GB를 추가하면 CPU 사용률이 79%에서 91%로 증가하므로 처리량은 12%만 증가합니다. 이 모델을 사용하면 컴퓨터 소유자는 처음에는 메모리를 추가하는 것이 좋은 투자이지만 두 번째에는 그렇지 않다고 결정할 수 있습니다.

18.4.8.2 로드 및 연결

활성 프로세스를 생성하는 첫 번째 단계는 프로그램을 주 메모리에 로드하고 프로세스 이미지(아래)를 생성하는 것입니다. 이는 대부분의 시스템에 대한 일반적인 시나리오를 보여줍니다. 애플리케이션은 컴파일되거나 어셈블된 개체 코드 형태의 여러 모듈로 구성됩니다. 이 모듈은 모듈 간 참조를 확인하고 동시에 라이브러리 루틴에 대한 참조를 확인하기 위해 연결됩니다. 라이브러리 루틴 자체는 프로그램에 통합되거나 공유 코드로 참조될 수 있으며, 이는 런타임 시 운영 체제에서 제공해야 합니다.

로딩 기능.

로드 및 연결된 장면.

  • 로드 중

위 다이어그램에서 로더는 로드 모듈을 위치부터 시작하여 메인 메모리에 배치합니다. 프로그램을 로드할 때 주소 지정 요구 사항이 충족되어야 합니다. 일반적으로 다음과 같은 세 가지 접근 방식을 취할 수 있습니다.

  1. 절대적으로 로드됨.

절대 로드 절대 로더에서는 주어진 로드 모듈이 항상 주 메모리의 동일한 위치에 로드되어야 합니다. 따라서 로더에 제공되는 로드 모듈에서 모든 주소 참조는 특정 또는 절대 주 메모리 주소를 가리켜야 합니다. 예를 들어 위 다이어그램의 x가 위치 1024라면 로드 모듈의 해당 메모리 영역에 할당된 첫 번째 단어의 주소는 1024입니다.

프로그램 내의 메모리 참조에 특정 주소 값을 할당하는 것은 프로그래머가 수행하거나 컴파일 또는 어셈블리 타임에 수행할 수 있습니다. 전자의 접근 방식에는 몇 가지 단점이 있습니다. 첫째, 모든 프로그래머는 모듈을 주 메모리에 배치하기 위해 예상되는 할당 전략을 알아야 하며, 둘째, 모듈 본문의 삽입 또는 삭제와 관련하여 프로그램을 수정하는 경우 모든 주소를 변경해야 합니다. 따라서 아래 그림과 같이 프로그램의 메모리 참조가 기호로 표시되도록 허용한 다음 컴파일 또는 어셈블리 시간에 이러한 기호 참조를 확인하는 것이 좋습니다. 명령어나 데이터 항목에 대한 각 참조는 처음에 기호로 표시됩니다. 절대 로더에 대한 입력을 위해 모듈을 준비할 때 어셈블러 또는 컴파일러는 아래 그림 b와 같이 이러한 모든 참조를 특정 주소(이 경우 위치 1024에서 시작하여 로드된 모듈의 경우)로 변환합니다.

  1. 재배치 가능한 로딩.

로드하기 전에 메모리 참조를 특정 주소에 바인딩하는 재배치 가능 로드의 단점은 결과 로드 모듈이 주 메모리의 한 영역에만 배치될 수 있다는 것입니다. 그러나 많은 프로그램이 메인 메모리를 공유하는 경우 특정 모듈을 메모리의 어느 영역에 로드할지 미리 결정하는 것은 바람직하지 않을 수 있습니다. 이 결정은 로드 시 가장 잘 이루어지므로 주 메모리의 어느 곳에나 위치할 수 있는 로드 모듈이 필요합니다.

이 새로운 요구 사항을 충족하기 위해 어셈블러나 컴파일러는 실제 주 메모리 주소(절대 주소)가 아니라 일부 알려진 지점(예: 프로그램 시작)을 기준으로 한 주소를 생성합니다. 이 기술은 아래 그림 c에 나와 있습니다. 로드된 모듈의 시작 부분에는 상대 주소 0이 할당되고, 모듈 내의 다른 모든 메모리 참조는 모듈의 시작 부분을 기준으로 표현됩니다.

모든 메모리 참조는 상대 형식으로 표현되므로 로더가 모듈을 필요한 위치에 배치하는 것은 간단한 작업이 됩니다. 위치부터 시작하여 모듈을 로드하려는 경우 로더는 모듈을 메모리에 로드할 때 각 메모리 참조에 추가하기만 하면 됩니다. 이 작업을 지원하려면 로드 모듈에는 주소 참조가 있는 위치와 주소 참조를 해석하는 방법을 로더에 알려주는 정보가 포함되어야 합니다(보통 프로그램 소스와 관련되지만 현재 위치와 같은 프로그램의 다른 지점과 관련될 수도 있음). 이 정보 세트는 컴파일러나 어셈블러에 의해 준비되며 종종 재배치 사전이라고 불립니다.

  1. 동적 런타임 로딩.

재배치 로딩은 상대적으로 일반적이며 절대 로딩에 비해 분명한 장점이 있습니다. 그러나 다중 프로그래밍 환경에서는 가상 메모리에 의존하지 않는 환경에서도 재배치 가능한 로딩 방식은 여전히 ​​부족합니다. 프로세서 활용도를 최대화하려면 프로세스 이미지를 메인 메모리에서 교체해야 합니다. 주 메모리 활용도를 최대화하기 위해 우리는 프로세스 이미지를 다른 시간에 다른 위치로 다시 교환할 수 있기를 원합니다. 따라서 일단 로드된 프로그램은 디스크로 스왑된 다음 다른 위치에서 다시 스왑될 수 있습니다. 초기 로드 시 메모리 참조가 절대 주소에 바인딩된 경우 이는 불가능합니다.

또 다른 접근 방식은 런타임에 실제로 필요할 때까지 절대 주소 계산을 연기하는 것입니다. 이를 위해 로드 모듈은 상대 형식의 모든 메모리 참조와 함께 주 메모리에 로드됩니다(위의 c). 절대 주소는 명령어가 실제로 실행될 때까지 계산되지 않습니다. 이 기능으로 인해 성능이 저하되지 않도록 하려면 소프트웨어가 아닌 특수 프로그램이나 하드웨어를 통해 수행해야 합니다.

동적 주소 계산은 완전한 유연성을 제공합니다. 프로그램은 메인 메모리의 어떤 영역에도 로드될 수 있으며, 이후에 프로그램의 실행이 중단될 수 있으며 프로그램은 메인 메모리에서 호출되었다가 나중에 다른 위치로 호출될 수 있습니다.

  • 링크

링커의 기능은 개체 모듈 집합을 입력으로 받아 로더에 전달되는 프로그램 및 데이터 모듈의 통합 집합으로 구성된 로드 모듈을 생성하는 것입니다. 각 개체 모듈 내에는 다른 모듈의 위치에 대한 주소 참조가 있을 수 있습니다. 이러한 각 참조는 연결되지 않은 개체 모듈에서만 기호로 표시될 수 있습니다. 링커는 모든 개체 모듈의 연속 연결인 로드 모듈을 만듭니다. 각 모듈 내 참조는 기호 주소에서 로드된 전체 모듈 내의 위치에 대한 참조로 변경되어야 합니다. 예를 들어 그림 7의 모듈 A에는 모듈 B에 대한 프로시저 호출이 포함되어 있습니다. 이러한 모듈이 로드된 모듈에 결합되면 모듈 B에 대한 기호 참조는 로드된 모듈에서 B의 진입점 위치에 대한 특정 참조로 변경됩니다.

링크 편집기 이 주소에 대한 링크의 성격은 생성되는 로드 모듈의 유형과 링크가 발생하는 시기에 따라 달라집니다(위의 표 b). 일반적으로 재배치 가능한 로드 모듈이 필요한 경우 일반적으로 다음과 같이 연결됩니다. 컴파일되거나 어셈블된 각 개체 모듈은 개체 모듈의 시작 부분을 기준으로 한 참조로 생성되며 이러한 모든 모듈은 로드 모듈의 원본을 기준으로 한 모든 참조를 포함하는 단일 재배치 가능한 로드 모듈에 배치됩니다. 이 모듈은 재배치 가능한 로딩 또는 동적 런타임 로딩에 대한 입력으로 사용될 수 있습니다.

재배치 가능한 로드 모듈을 생성하는 링커를 종종 링크 편집기라고 합니다. 아래 이미지는 링크 편집기 기능을 보여줍니다.

로딩과 마찬가지로 일부 링크 기능도 지연될 수 있습니다. 동적 연결이라는 용어는 로드 모듈이 생성될 때까지 특정 외부 모듈의 연결을 연기하여 로드 시간이나 런타임 시 해결될 수 있는 다른 프로그램에 대한 해결되지 않은 참조를 로드 모듈에 포함시키는 관행을 가리키는 데 사용됩니다.

로드 시 동적 연결을 위해서는 다음 단계가 필요합니다. 로드할 로드 모듈(애플리케이션 모듈)을 메모리로 읽어들이고, 외부 모듈(대상 모듈)에 대한 참조가 있으면 로더가 대상 모듈을 찾아서 로드한 다음, 참조를 애플리케이션 모듈의 시작 부분에서 메모리의 상대 주소로 변경합니다. 동적 연결은 소위 정적 연결에 비해 몇 가지 장점이 있습니다.

  1. 운영 체제 유틸리티나 기타 일반적인 루틴이 될 수 있는 대상 모듈의 변경되거나 업그레이드된 버전을 통합하는 것이 더 쉬워졌습니다. 정적 링크를 사용하면 이러한 지원 모듈을 변경하려면 전체 애플리케이션 모듈을 다시 링크해야 하는데, 이는 비효율적일 뿐만 아니라 어떤 경우에는 불가능할 수도 있습니다. 예를 들어, 개인용 컴퓨터 세계에서 대부분의 상용 소프트웨어는 로드 가능한 모듈로 출시됩니다. 소스 코드와 개체 버전은 출시되지 않습니다.

  2. 동적으로 링크된 파일에 객체 코드를 포함하면 자동 코드 공유가 가능해집니다. 운영 체제는 해당 코드를 로드하고 연결하기 때문에 여러 응용 프로그램이 동일한 개체 코드를 사용하고 있음을 인식할 수 있습니다. 이 정보를 사용하여 각 응용 프로그램에 대한 복사본을 로드할 필요 없이 개체 코드의 단일 복사본을 로드하고 두 응용 프로그램에 연결할 수 있습니다.

  3. 독립 소프트웨어 개발자가 널리 사용되는 운영 체제(Linux 등)의 기능을 확장하는 것이 더 쉽습니다. 개발자는 다양한 애플리케이션에 유용한 새로운 기능을 고안하고 이를 동적으로 연결된 모듈로 패키징할 수 있습니다.

런타임 동적 연결을 사용하면 일부 연결이 실행 시간까지 연기됩니다. 개체 모듈에 대한 외부 참조는 로드된 프로그램에 남아 있습니다. 존재하지 않는 모듈이 호출되면 운영 체제는 모듈을 찾아 로드한 후 호출 모듈에 연결합니다. 이러한 모듈은 일반적으로 공유 가능하며 Windows 환경에서는 이를 DLL(동적 링크 라이브러리)이라고 합니다. 따라서 프로세스가 이미 동적으로 연결된 공유 모듈을 사용하고 있다면 해당 모듈은 주 메모리에 있고 새 프로세스는 로드된 모듈에 간단히 연결할 수 있습니다.

두 개 이상의 프로세스가 DLL 모듈을 공유하지만 다른 버전의 모듈이 필요한 경우 DLL을 사용하면 일반적으로 DLL 지옥이라고 알려진 문제가 발생할 수 있습니다. 예를 들어, 응용 프로그램이나 시스템 기능이 다시 설치되어 이전 버전의 DLL 파일이 함께 포함될 수 있습니다.

우리는 동적 로딩을 통해 로드된 전체 모듈을 이동할 수 있지만 모듈의 구조는 정적이며 프로세스 실행 전체와 한 실행에서 다음 실행까지 변경되지 않은 상태로 유지된다는 것을 확인했습니다. 그러나 어떤 경우에는 트랜잭션 처리 애플리케이션(예: 항공 예약 시스템 또는 은행 애플리케이션)과 같이 실행 전에 어떤 개체 모듈이 필요한지 결정하는 것이 불가능합니다. 여기서 트랜잭션의 성격에 따라 어떤 프로그램 모듈이 필요한지 결정되고 해당 모듈이 적절하게 로드되어 기본 프로그램과 연결됩니다. 이러한 동적 링커를 사용하면 참조되지 않는 한 프로그램 단위에 메모리를 할당할 필요가 없다는 장점이 있습니다. 이 기능은 분할된 시스템을 지원하는 데 사용됩니다.

추가적인 개선이 가능합니다. 애플리케이션은 호출될 수 있는 모든 모듈이나 진입점의 이름을 알 필요가 없습니다. 예를 들어, 각기 다른 드라이버 패키지로 구동되는 다양한 플로터와 함께 작동하도록 플로팅 프로그램을 작성할 수 있습니다. 응용 프로그램은 다른 프로세스나 구성 파일에서 시스템에 현재 설치된 플로터의 이름을 조회할 수 있으며, 이를 통해 응용 프로그램 사용자는 응용 프로그램 작성 시 존재하지 않는 새 플로터를 설치할 수 있습니다.

18.5 스레드

스레드는 자체 프로그램 카운터, 시스템 레지스터 및 스택이 있는 프로세스 코드를 통한 실행 흐름이며, 병렬 처리를 통해 애플리케이션 성능을 향상시키는 널리 사용되는 방법입니다. 경량 프로세스라고도 불리는 스레드는 하이퍼스레딩을 기존 프로세스와 동일하게 줄여 운영 체제 성능을 향상시키는 소프트웨어 접근 방식을 나타냅니다. 각 스레드는 하나의 프로세스에만 속하며 프로세스 외부에는 스레드가 존재할 수 없습니다. 각 스레드는 별도의 제어 흐름을 나타냅니다.

일부 운영 체제는 사용자 수준 스레드와 커널 수준 스레드의 조합을 제공하며 Solaris는 이러한 조합된 접근 방식의 좋은 예입니다. 복합 시스템에서는 동일한 애플리케이션의 여러 스레드가 여러 프로세서에서 병렬로 실행될 수 있으며 차단 시스템 호출이 전체 프로세스를 차단할 필요가 없습니다.

18.5.1 스레드와 프로세스

아래 그림 (a)에서는 각각 고유한 주소 공간과 단일 제어 스레드가 있는 세 가지 기존 프로세스를 볼 수 있습니다. 대조적으로, 아래 그림 (b)에서는 세 개의 제어 스레드가 있는 단일 프로세스를 볼 수 있습니다. 두 경우 모두 세 개의 스레드가 있지만 아래 (a)에서는 각 스레드가 서로 다른 주소 공간에서 실행되고 아래 (b)에서는 세 스레드가 동일한 주소 공간을 공유합니다. 다중 스레드 프로세스가 단일 CPU 시스템에서 실행되면 스레드가 차례로 실행됩니다. 여러 프로세스 사이를 앞뒤로 전환함으로써 시스템은 병렬로 실행되는 독립적인 순차적 프로세스의 환상을 제공합니다. 멀티스레딩도 같은 방식으로 작동합니다. CPU가 스레드 사이를 빠르게 앞뒤로 전환하여 스레드가 실제보다 느린 CPU에서라도 병렬로 실행되는 것처럼 보이게 합니다. 프로세스에 3개의 계산 바인딩 스레드가 있으면 스레드가 병렬로 실행되는 것처럼 보이며 각 스레드는 실제 CPU 속도의 1/3 속도로 CPU에서 실행됩니다.

(a) 각각 하나의 스레드를 갖는 세 개의 프로세스. (b) 프로세스에는 세 개의 스레드가 있습니다.

프로세스의 서로 다른 스레드는 서로 다른 프로세스만큼 독립적이지 않습니다. 모든 스레드는 정확히 동일한 주소 공간을 가지며, 이는 동일한 전역 변수도 공유한다는 의미입니다. 각 스레드는 프로세스 주소 공간 내의 모든 메모리 주소에 액세스할 수 있으므로 한 스레드는 다른 스레드의 스택을 읽고 쓰고 지울 수도 있습니다. 두 가지 이유로 스레드 간 보호가 없습니다. 첫째, 불가능합니다. 둘째, 보호가 없어야 합니다. 차이점은 서로 다른 프로세스가 서로 다른 사용자로부터 생성될 수 있고 서로 경쟁할 수 있다는 것입니다. 프로세스는 경쟁하기보다는 협력할 수 있도록 여러 스레드를 생성한 한 사용자가 항상 소유합니다. 주소 공간을 공유하는 것 외에도 모든 스레드는 다음 표에 표시된 대로 동일한 열린 파일 집합, 하위 프로세스, 경고, 신호 등을 공유할 수 있습니다. 따라서 세 가지 프로세스가 본질적으로 관련이 없는 경우 위의 다이어그램 (a)의 구성이 사용되지만, 세 개의 스레드가 실제로 동일한 작업의 일부이고 서로 적극적으로 긴밀하게 협력하는 경우 위의 다이어그램 (b)가 적합합니다.

프로세스별 데이터 항목 스레드별 데이터 항목

주소 공간 전역 변수 파일 열기 하위 프로세스 보류 중인 알람 신호 및 신호 처리 계정 정보 프로그램 카운터 등록하다 스택 상태

첫 번째 열의 항목은 스레드 속성이 아닌 프로세스 속성입니다. 예를 들어, 한 스레드가 파일을 열면 프로세스의 다른 스레드가 해당 파일을 볼 수 있으며 해당 스레드는 파일을 읽고 쓸 수 있습니다. 프로세스는 스레드가 아닌 리소스 관리 단위이므로 이는 논리적입니다. 각 스레드에 자체 주소 공간, 열린 파일, 보류 중인 경고 등이 있는 경우 별도의 프로세스가 됩니다. 스레드 개념을 통해 우리가 달성하려는 것은 여러 실행 스레드가 리소스 세트를 공유하여 긴밀하게 협력하여 특정 작업을 수행할 수 있는 기능입니다.

Windows 프로세스 및 스레드 비교 차트.

기존 프로세스(즉, 스레드가 하나만 있는 프로세스)와 마찬가지로 스레드는 실행 중, 차단됨, 준비됨 또는 종료됨 등 여러 상태 중 하나일 수 있습니다. 실행 중인 스레드에는 현재 CPU가 있고 활성 상태입니다. 대신 차단된 스레드는 일부 이벤트가 차단 해제되기를 기다리고 있습니다. 예를 들어 스레드가 키보드에서 읽는 시스템 호출을 실행하면 입력이 입력될 때까지 차단됩니다. 스레드는 일부 외부 이벤트가 발생하거나 다른 스레드가 차단 해제될 때까지 기다리면서 차단될 수 있습니다. 준비된 스레드는 차례가 오자마자 실행되도록 예약되어 있으며 스레드 상태 간의 전환은 프로세스 상태 간의 전환과 동일합니다.

아래 그림과 같이 각 스레드에는 자체 스택이 있다는 점을 인식하는 것이 중요합니다. 각 스레드의 스택에는 호출되었지만 아직 반환되지 않은 각 프로시저에 대한 프레임이 하나씩 포함되어 있습니다. 이 프레임에는 프로시저 호출이 완료될 때 사용할 프로시저의 지역 변수와 반환 주소가 포함되어 있습니다. 예를 들어 프로시저 X가 프로시저 Y를 호출하고 Y가 프로시저 Z를 호출하는 경우 Z가 실행되면 X, Y 및 Z에 대한 프레임이 모두 스택에 있게 됩니다. 각 스레드는 일반적으로 서로 다른 프로시저를 호출하므로 실행 기록도 다릅니다. 이것이 바로 각 스레드마다 자체 스택이 필요한 이유입니다.

각 스레드에는 자체 스택이 있습니다.

여러 스레드가 있는 경우 프로세스는 일반적으로 단일 스레드로 시작됩니다. 이 스레드에는 스레드 생성과 같은 라이브러리 프로시저를 호출하여 새 스레드를 생성하는 기능이 있습니다. 스레드 생성을 위한 매개변수는 새 스레드를 실행할 프로세스의 이름을 지정합니다. 생성된 스레드의 주소 공간에서 자동으로 실행되므로 새 스레드의 주소 공간에 대해 아무것도 지정할 필요가 없습니다(또는 가능하지도 않습니다). 때때로 스레드는 상위-하위 관계를 갖는 계층적이지만, 종종 이 관계는 존재하지 않고 모든 스레드가 동일합니다. 계층적 관계가 있는지 여부에 관계없이 스레드를 생성하면 일반적으로 새 스레드의 이름을 지정하는 데 사용되는 스레드 식별자가 반환됩니다.

스레드가 작업을 완료하면 ThreadExit와 같은 라이브러리 프로시저를 호출하여 종료할 수 있습니다. 그런 다음 사라지고 더 이상 예약할 수 없습니다. 일부 스레딩 시스템에서 스레드는 스레드 조인과 같은 프로시저를 호출하여 (특정) 스레드가 종료될 때까지 기다릴 수 있습니다. 이 프로시저는 (특정) 스레드가 종료될 때까지 호출 스레드를 차단합니다. 이 점에서 스레드 생성 및 종료는 프로세스 생성 및 종료와 매우 유사하며 거의 동일한 옵션을 갖습니다.

또 다른 일반적인 스레드 호출은 스레드 생성입니다. 이를 통해 한 스레드는 자발적으로 CPU 시간 분할을 포기하고 다른 스레드가 실행되도록 할 수 있습니다. 프로세스처럼 다중 프로그램을 실제로 실행하는 데 클럭 인터럽트가 없기 때문에 이 메커니즘이 중요합니다. 따라서 스레드는 정중해야 하며 때때로 적극적으로 CPU를 포기하여 다른 스레드가 실행될 기회를 제공해야 합니다. 다른 호출을 통해 한 스레드는 다른 스레드가 일부 작업을 완료할 때까지 기다리거나, 스레드가 일부 작업을 완료했음을 발표할 때까지 기다릴 수 있습니다.

스레드는 유용한 경우가 많지만 프로그래밍 모델에 많은 복잡성을 초래하기도 합니다. 먼저 UNIX 포크 시스템 호출의 효과를 고려하십시오. 상위 프로세스에 여러 스레드가 있는 경우 하위 프로세스에도 스레드가 있어야 합니까? 그렇지 않으면 이 모든 것이 필요할 수 있으므로 프로세스가 제대로 작동하지 않을 수 있습니다.

그러나 하위 프로세스가 상위 프로세스와 동일한 수의 스레드를 얻는 경우 상위 프로세스의 스레드가 읽기 호출(예: 키보드의 읽기 호출)에서 차단되면 어떻게 될까요? 이제 키보드에 두 개의 스레드가 차단되어 있습니까? 하나는 상위 스레드에 있고 다른 하나는 하위 스레드에 있습니까? 한 줄이 입력되면 두 스레드 모두 그 줄의 복사본을 얻습니까? 부모님만? 아이들만? 개방형 네트워크 연결에서도 동일한 문제가 발생합니다.

또 다른 유형의 문제는 많은 데이터 구조를 공유하는 스레드와 관련이 있습니다. 다른 스레드가 아직 파일을 읽고 있는 동안 한 스레드가 파일을 닫으면 어떻게 되나요? 스레드가 메모리가 너무 적다는 것을 알아차리고 더 많은 메모리를 할당하기 시작한다고 가정해 보겠습니다. 도중에 스레드 전환이 발생하고 새 스레드도 메모리가 너무 적다는 것을 인식하고 더 많은 메모리를 할당하기 시작합니다. 결과적으로 메모리가 두 번 할당될 수 있습니다. 이러한 문제는 약간의 노력으로 해결될 수 있지만 멀티스레드 프로그램이 올바르게 작동하려면 신중한 고려와 설계가 필요합니다.

18.5.2 멀티스레딩 모델

멀티스레딩 모델에는 세 가지 유형이 있습니다.

  • 다대다 관계. 이 모델에서는 많은 사용자 수준 스레드가 더 작거나 동일한 수의 커널 스레드로 다중화되며, 이는 특정 응용 프로그램이나 특정 컴퓨터에 따라 다를 수 있습니다. 이 모델에서 개발자는 필요한 만큼 많은 사용자 스레드를 생성할 수 있으며 해당 커널 스레드는 여러 프로세서에서 병렬로 실행될 수 있습니다.

  • 다대일 관계. 다대일 모델은 여러 사용자 수준 스레드를 하나의 커널 수준 스레드로 매핑하고 스레드 관리는 사용자 공간에서 수행됩니다. 스레드가 차단 시스템 호출을 수행하면 전체 프로세스가 차단됩니다. 한 번에 하나의 스레드만 코어에 액세스할 수 있으므로 여러 스레드가 여러 프로세서에서 병렬로 실행될 수 없습니다. 사용자 수준 스레드 라이브러리가 운영 체제에서 구현되는 경우 시스템은 커널 스레드에 대한 다대일 관계 모드를 지원하지 않습니다.

  • 일대일 관계. 사용자 수준 스레드와 커널 수준 스레드 간에는 일대일 관계가 있으며 이 모델은 다대일 모델보다 더 많은 동시성을 제공합니다. 한 스레드가 차단 시스템 호출을 수행하면 다른 스레드가 실행되도록 허용하여 마이크로프로세서에서 여러 스레드의 병렬 실행을 지원합니다.

이 모델의 단점은 사용자 스레드를 생성하려면 해당 커널 스레드가 필요하다는 것입니다. OS/2, Windows NT 및 Windows 2000은 일대일 관계 모델을 사용합니다.

18.5.3 멀티스레딩의 장점

멀티스레드 프로그래밍의 이점은 크게 네 가지 범주로 나눌 수 있습니다.

  • 응답성. 다중 스레드 대화형 응용 프로그램은 프로그램의 일부가 차단되거나 긴 작업을 수행하는 경우에도 프로그램이 계속 실행되도록 하여 사용자에 대한 응답성을 향상시킬 수 있습니다. 이 품질은 사용자 인터페이스를 디자인할 때 특히 유용합니다. 예를 들어, 시간이 많이 걸리는 작업이 수행되도록 하는 버튼을 사용자가 클릭하면 어떤 일이 발생하는지 생각해 보세요. 단일 스레드 애플리케이션은 작업이 완료될 때까지 사용자에게 응답하지 않습니다. 대조적으로, 시간이 많이 걸리는 작업이 별도의 스레드에서 수행되는 경우 애플리케이션은 사용자에게 계속 응답합니다.

  • 리소스 공유. 프로세스는 프로그래머가 명시적으로 예약해야 하는 공유 메모리 및 메시지 전달과 같은 기술을 통해서만 리소스를 공유할 수 있지만 기본적으로 스레드는 자신이 속한 프로세스의 메모리와 리소스를 공유합니다. 코드와 데이터를 공유하면 애플리케이션이 동일한 주소 공간 내에서 여러 개의 서로 다른 활성 스레드를 가질 수 있다는 이점이 있습니다.

  • 경제. 프로세스 생성을 위해 메모리와 리소스를 할당하는 데는 비용이 많이 듭니다. 스레드는 자신이 속한 프로세스의 리소스를 공유하므로 스레드를 생성하고 컨텍스트 전환하는 것이 더 경제적입니다. 오버헤드의 차이를 경험적으로 측정하기는 어려울 수 있지만 일반적으로 스레드보다 프로세스를 생성하고 관리하는 데 더 많은 시간이 걸립니다. 예를 들어, Solaris에서 프로세스를 생성하는 것은 스레드를 생성하는 것보다 약 30배 느리고 컨텍스트 전환은 약 5배 더 느립니다.

  • 확장성. 멀티스레딩의 이점은 스레드가 서로 다른 처리 코어에서 병렬로 실행될 수 있는 다중 프로세서 아키텍처에서 훨씬 더 큽니다. 단일 스레드 프로세스는 사용 가능한 프로세서 수에 관계없이 하나의 프로세서에서만 실행될 수 있습니다.

요약하자면, 스레드의 장점/장점은 컨텍스트 전환 시간을 최소화하고, 프로세스 내 동시성을 제공하고, 효율적인 통신을 제공하며, 스레드의 보다 경제적인 생성 및 컨텍스트 전환을 제공한다는 것입니다. 멀티 프로세서 아키텍처 활용 – 멀티 프로세서 아키텍처에서 멀티 스레딩의 이점을 크게 늘릴 수 있습니다.

18.5.4 사용자 및 커널 스레드

스레드는 사용자 수준 스레드커널 수준 스레드로 나눌 수 있습니다.

사용자 스레드에서 스레드 관리의 모든 작업은 애플리케이션에 의해 수행되며 커널은 스레드의 존재를 알지 못합니다. 스레드 라이브러리에는 스레드 생성 및 삭제, 스레드 간 메시지 및 데이터 전달, 스레드 실행 예약, 스레드 컨텍스트 저장 및 복원을 위한 코드가 포함되어 있습니다. 애플리케이션은 단일 스레드로 시작하여 해당 스레드에서 실행을 시작합니다. 사용자 수준 스레드 생성 및 관리는 일반적으로 빠릅니다.

커널 수준 스레드에 비해 사용자 수준 스레드의 장점: 스레드 전환에는 커널 모드 권한이 필요하지 않으며, 사용자 수준 스레드는 모든 운영 체제에서 실행될 수 있고, 계획은 애플리케이션별로 다를 수 있으며, 사용자 수준 스레드는 생성 및 관리가 빠릅니다.

사용자 수준 스레드의 단점: 일반적인 운영 체제에서는 대부분의 시스템 호출이 차단되며 다중 스레드 응용 프로그램은 다중 처리를 활용할 수 없습니다.

커널 수준 스레드에서 스레드 관리는 커널에 의해 수행됩니다. 응용 프로그램 영역에는 스레드 관리 코드가 없으며 운영 체제는 커널 스레드를 직접 지원합니다. 모든 애플리케이션은 애플리케이션의 모든 스레드를 지원하는 단일 프로세스를 통해 다중 스레드로 프로그래밍될 수 있습니다.

커널은 전체 프로세스와 프로세스의 각 스레드에 대한 컨텍스트 정보를 유지 관리합니다. 커널의 스케줄링은 스레드 기반으로 완료됩니다. 커널은 커널 공간에서 스레드 생성, 스케줄링 및 관리를 수행합니다. 커널 스레드의 생성 및 관리 속도는 일반적으로 사용자 스레드의 속도보다 느립니다.

커널 수준 스레드의 장점: 커널은 여러 프로세스의 동일한 프로세스에서 여러 스레드를 동시에 예약할 수 있습니다. 프로세스의 한 스레드가 차단되면 커널은 동일한 프로세스의 다른 스레드를 예약할 수 있습니다. 커널 루틴 자체는 다중 스레드일 수 있습니다.

커널 수준 스레드의 단점: 커널 스레드는 일반적으로 사용자 스레드보다 생성 및 관리 속도가 느리고 동일한 프로세스 내에서 한 스레드에서 다른 스레드로 제어를 전송하려면 모드를 커널로 전환해야 합니다.

사용자 스레드와 커널 스레드의 비교 차트.

스레드에는 복잡한 상태 전환이 포함됩니다. 다음은 운영 체제의 일반적인 전환 다이어그램입니다.

사용자 수준 스레드 상태와 프로세스 상태 간의 관계 예.

Windows 스레드 상태 전환 다이어그램.

Linux 프로세스 및 스레드 모델.

Solaris 사용자 스레드 및 LWP 상태.

사용자 수준 스레드와 커널 수준 스레드의 차이점은 다음과 같습니다.

사용자 스레드 커널 스레드

생성 및 관리 속도 더 빠르게 더 느리게

구현 방법 사용자 수준 스레드 라이브러리에 의해 구현됨 직접 운영 체제 지원

OS 종속성 모든 운영 체제에서 실행됩니다. 운영 체제별

명명 방법 사용자 수준에서 제공되는 지원을 사용자 수준 스레딩이라고 합니다. 커널이 제공할 수 있는 지원을 커널 수준 스레딩이라고 합니다.

멀티코어 활용 다중 처리를 활용할 수 없습니다. 커널 루틴 자체는 다중 스레드일 수 있습니다.

프로세스와 스레드의 차이점은 다음과 같습니다.

프로세스 실

크기 헤비급 프로세스 경량 프로세스(Linux와 유사한 경우에만 해당)

토글 프로세스 전환에는 운영 체제와의 상호 작용이 필요합니다. 스레드 전환에는 운영 체제 호출 및 커널 인터럽트 발생이 필요하지 않습니다.

공유 여러 프로세스에서 각 프로세스는 동일한 코드를 실행하지만 자체 메모리와 파일 리소스를 갖습니다. 모든 스레드는 동일한 열린 파일 세트, 하위 프로세스를 공유합니다.

차단 서비스 프로세스가 차단되면 해당 서비스 프로세스가 차단될 때까지 다른 서비스 프로세스를 실행할 수 없습니다. 하나의 서비스 스레드가 차단되어 대기하는 동안 동일한 작업의 두 번째 스레드가 실행될 수 있습니다.

중복 다중 중복 프로세스는 다중 스레드 프로세스보다 더 많은 리소스를 사용합니다. 다중 스레드 프로세스는 여러 중복 프로세스보다 적은 리소스를 사용합니다.

독립성 다중 처리에서는 각 프로세스가 다른 프로세스와 독립적으로 실행됩니다. 한 스레드는 다른 스레드의 스택을 읽고 쓰거나 완전히 지울 수도 있습니다.

스레드를 구현하는 두 가지 주요 장소는 사용자 공간과 커널 공간, 그리고 이들의 하이브리드 구현입니다. 이러한 방법과 장점 및 단점은 아래에 설명되어 있습니다.

  • 사용자 공간 스레드

이 접근 방식은 스레드 패키지를 완전히 사용자 공간에 배치하고 커널은 이에 대해 아무것도 모릅니다. 커널에 관한 한, 이는 일반적인 단일 스레드 프로세스를 관리합니다. 첫 번째이자 가장 확실한 장점은 스레드를 지원하지 않는 운영 체제에서 사용자 수준 스레드 패키지를 구현할 수 있다는 것입니다. 과거의 모든 운영 체제는 이 범주에 속했으며 일부 운영 체제도 여전히 그렇습니다. 이 접근 방식을 사용하면 스레드가 라이브러리에 의해 구현됩니다. 이러한 모든 구현은 아래 그림과 같이 전체 구조가 동일합니다. 스레드는 스레드를 관리하는 프로세스 모음인 런타임 시스템에서 실행됩니다.

왼쪽: 사용자 수준 스레드 패키지. 오른쪽: 커널이 관리하는 스레드 패키지.

사용자 공간에서 스레드를 관리할 때 각 프로세스에는 해당 프로세스 내의 스레드를 추적하기 위한 자체 전용 스레드 테이블이 필요합니다. 이 테이블은 각 스레드의 프로그램 카운터, 스택 포인터, 레지스터, 상태 등과 같은 스레드별 속성만 추적한다는 점을 제외하면 커널의 프로세스 테이블과 유사합니다. 스레드 테이블은 런타임 시스템에 의해 관리되며, 스레드가 준비 상태 또는 차단 상태로 이동하면 스레드를 다시 시작하는 데 필요한 정보가 커널이 프로세스 테이블에 프로세스 정보를 저장하는 것과 똑같은 방식으로 스레드 테이블에 저장됩니다.

스레드가 프로세스의 다른 스레드가 일부 작업을 완료할 때까지 기다리는 등 로컬로 차단될 수 있는 작업을 수행할 때 런타임 시스템 프로시저를 호출합니다. 이 프로세스는 스레드가 차단된 상태에 있어야 하는지 여부를 확인합니다. 그렇다면 스레드 테이블에 스레드의 레지스터(즉, 자신의 레지스터)를 저장하고 테이블에서 실행할 준비가 된 스레드를 찾은 다음 새 스레드가 저장한 값으로 머신 레지스터를 다시 로드합니다. 스택 포인터와 프로그램 카운터가 전환되면 새 스레드가 자동으로 다시 시작됩니다. 기계에 모든 레지스터를 저장하는 하나의 명령어와 모든 레지스터를 로드하는 또 다른 명령어가 있는 경우 전체 스레드 전환은 몇 개의 명령어만으로 수행될 수 있습니다. 이렇게 하면 스레드 전환이 커널을 잡는 것보다 적어도 몇 배 더 빨라집니다. 이는 사용자 수준 스레딩 패키지를 선호하는 강력한 주장입니다.

그러나 프로세스에는 중요한 차이점이 있습니다. 예를 들어 스레드 중단을 호출하는 경우와 같이 스레드 실행이 일시적으로 끝나면 스레드 중단 코드는 스레드 정보를 스레드 테이블 자체에 저장할 수 있습니다. 또한 스레드 스케줄러를 호출하여 실행할 다른 스레드를 선택할 수도 있습니다. **스레드 상태와 스케줄러를 저장하는 프로시저는 로컬 프로시저일 뿐이므로 이를 호출하는 것이 커널을 호출하는 것보다 훨씬 효율적입니다.**다른 질문에서는 트랩이 필요하지 않고, 컨텍스트 전환이 필요하지 않으며, 메모리 캐시가 플러시되지 않습니다. 이러한 기능을 사용하면 스레드 예약이 매우 빨라집니다.

사용자 수준 스레드에는 다른 장점도 있습니다. 이를 통해 각 프로세스는 고유한 사용자 정의 스케줄링 알고리즘을 가질 수 있습니다. 예를 들어 가비지 수집기 스레드가 있는 일부 애플리케이션의 경우 불편한 시간에 스레드가 중지되는 것에 대해 걱정할 필요가 없습니다. 또한 커널 스레드에는 항상 커널에 일부 테이블 공간과 스택 공간이 필요하기 때문에 확장성이 더 좋습니다. 이는 스레드 수가 많은 경우 문제가 될 수 있습니다.

더 나은 성능에도 불구하고 사용자 수준 스레딩 패키지에는 여전히 몇 가지 중요한 문제가 있습니다. 첫 번째는 차단 시스템 호출을 구현하는 방법에 대한 문제입니다. 스레드가 키를 누르기 전에 키보드를 읽는다고 가정하면 모든 스레드가 중지되므로 스레드가 실제로 시스템 호출을 실행하는 것은 허용되지 않습니다. 처음에 스레드를 갖는 주요 목표 중 하나는 각 스레드가 차단 호출을 사용하도록 허용하지만 차단된 스레드 하나가 다른 스레드에 영향을 미치는 것을 방지하는 것입니다. 이는 차단 시스템 호출로 인해 쉽게 달성되지 않습니다.

시스템 호출은 모두 비차단으로 변경될 수 있습니다(예: 버퍼링된 문자가 없으면 키보드에서 읽으면 0바이트만 반환됩니다). 하지만 운영 체제를 변경하도록 요구하는 것은 바람직하지 않습니다. 또한 사용자 수준 스레드에 대한 한 가지 주장은 기존 운영 체제에서 실행될 수 있다는 것입니다. 게다가 읽기의 의미를 변경하려면 많은 사용자 프로그램을 변경해야 합니다.

통화 차단 여부를 미리 알 수 있는 경우 다른 접근 방식을 사용할 수 있습니다. 대부분의 UNIX 버전에는 호출자가 예상 읽기가 차단되는지 여부를 알릴 수 있는 시스템 호출 select가 있습니다. 이 호출이 있으면 라이브러리 프로시저 읽기를 먼저 선택 호출을 수행한 다음 안전할 때만(즉, 차단하지 않음) 읽기 호출을 수행하는 새로운 프로시저로 대체할 수 있습니다. 읽기 호출이 차단되면 호출이 이루어지지 않고 다른 스레드가 실행됩니다. 다음에 시스템이 런타임 시 제어권을 얻게 되면 이제 읽기에 안전한지 다시 확인할 수 있습니다. 이 접근 방식을 사용하려면 시스템 호출 라이브러리의 일부를 다시 작성해야 하는데 이는 비효율적이고 보기에도 좋지 않지만 대안이 없습니다. 검사를 수행하기 위해 시스템 호출 주위에 배치된 코드를 재킷 또는 래퍼라고 합니다.

시스템 호출 차단 문제와 유사한 페이지 폴트 문제도 있습니다. 프로그램이 메모리에 없는 명령어를 호출하거나 해당 명령어로 점프하는 경우 페이지 오류가 발생하고 운영 체제는 누락된 명령어(및 인접 명령어)를 디스크에서 가져옵니다. 이를 페이지 오류라고 합니다. 필요한 지침을 찾아 읽는 동안 프로세스가 차단됩니다. 스레드가 페이지 오류를 일으키는 경우 커널은 스레드가 존재하는지조차 모르고 다른 스레드가 실행될 수 있더라도 디스크 I/O가 완료될 때까지 전체 프로세스를 자연스럽게 차단합니다.

사용자 수준 스레드 패키지의 또 다른 문제점은 하나의 스레드가 실행되기 시작하면 첫 번째 스레드가 자발적으로 CPU를 포기하지 않는 한 프로세스의 다른 스레드가 실행되지 않는다는 것입니다. 단일 프로세스 내에는 클록 인터럽트가 없으므로 프로세스를 라운드 로빈 방식(라운드 로빈)으로 예약할 수 없습니다. 스레드가 자발적으로 런타임 시스템에 들어가지 않는 한 스케줄러는 결코 기회를 얻지 못합니다.

스레드가 영원히 실행되는 문제에 대한 가능한 해결책 중 하나는 런타임 시스템이 제어권을 주기 위해 매초마다 클록 신호(인터럽트)를 요청하도록 하는 것입니다. 그러나 이는 프로그램에 있어 조잡하고 혼란스러울 수도 있습니다. 더 높은 빈도의 주기적인 클록 인터럽트가 항상 가능한 것은 아니며, 그래도 전체 오버헤드가 상당할 수 있습니다. 또한 스레드에는 시계 인터럽트가 필요할 수 있으며 이는 런타임 시스템의 시계 사용을 방해합니다.

사용자 수준 스레드에 대한 또 다른 가장 해로운 주장은 프로그래머가 스레드가 자주 차단되는 애플리케이션(예: 스레드가 지속적으로 시스템 호출을 수행하는 다중 스레드 웹 서버)에 있는 스레드를 원하는 경우가 많다는 것입니다. 커널이 시스템 호출 실행에 갇히게 되면 이전 스레드가 차단된 경우 커널은 스레드를 전환하기 위해 수행할 작업이 거의 없으며, 커널이 이를 수행하도록 하면 시스템 호출을 읽어도 안전한지 확인하기 위해 지속적으로 선택 시스템 호출을 수행할 필요가 없습니다. 본질적으로 완전히 CPU에 묶여 있고 거의 차단되지 않는 애플리케이션에 스레드를 사용하는 이유는 무엇입니까? 처음 n개의 소수를 계산하거나 스레드를 사용하여 체스를 두는 것을 진지하게 제안하는 사람은 아무도 없습니다. 그렇게 해도 아무런 이점이 없기 때문입니다.

  • 커널 공간 스레드

이제 커널이 스레드를 인식하고 관리하도록 만드는 것을 고려해 보겠습니다. 위 오른쪽 그림과 같이 모든 시스템에는 런타임 시스템이 필요하지 않습니다. 또한 각 프로세스에는 스레드 테이블이 없습니다. 대신 커널에는 시스템의 모든 스레드를 추적하는 스레드 테이블이 있습니다. 스레드가 새 스레드를 생성하거나 기존 스레드를 삭제하려는 경우 커널을 호출한 다음 커널 스레드 테이블을 업데이트하여 스레드를 생성하거나 삭제합니다.

커널의 스레드 테이블은 각 스레드의 레지스터, 상태 및 기타 정보를 보유합니다. 이 정보는 사용자 수준 스레드의 정보와 동일하지만 이제 사용자 공간(런타임 시스템)이 아닌 커널에 보관됩니다. 이 정보는 프로세스 상태로 알려진 단일 스레드 프로세스에 대해 기존 커널이 유지 관리하는 정보의 하위 집합입니다. 또한 커널은 프로세스를 추적하기 위해 기존 프로세스 테이블을 유지 관리합니다.

스레드를 차단할 수 있는 모든 호출은 시스템 호출로 구현되며, 이는 런타임 시스템 프로시저 호출보다 비용이 훨씬 더 많이 듭니다. 스레드가 차단되면 커널은 동일한 프로세스(준비된 경우)에서 다른 스레드를 실행하거나 다른 프로세스에서 스레드를 실행하도록 선택할 수 있습니다. 사용자 수준 스레드를 사용하면 런타임 시스템은 커널이 CPU를 차지할 때까지(또는 실행할 준비가 된 스레드가 없을 때까지) 자체 프로세스에서 스레드를 계속 실행합니다.

커널에서 스레드를 생성하고 삭제하는 데 상대적으로 높은 비용이 들기 때문에 일부 시스템은 환경적으로 올바른 접근 방식을 취하고 스레드를 재활용합니다. 스레드가 삭제되면 실행 불가능으로 표시되지만 커널 데이터 구조는 영향을 받지 않습니다. 나중에 새 스레드를 생성해야 할 때 이전 스레드가 다시 활성화되어 일부 오버헤드가 절약됩니다. 사용자 수준 스레드도 스레드 재활용을 수행할 수 있지만 스레드 관리 오버헤드가 훨씬 적기 때문에 그렇게 할 동기가 적습니다.

커널 스레드에는 새로운 비차단 시스템 호출이 필요하지 않습니다. 또한 프로세스의 스레드가 페이지 오류를 일으키는 경우 커널은 프로세스에 실행 가능한 다른 스레드가 있는지 쉽게 확인할 수 있으며, 그렇다면 필요한 페이지가 디스크에서 가져올 때까지 기다리는 동안 해당 스레드 중 하나를 실행할 수 있습니다. 가장 큰 단점은 시스템 호출 비용이 비싸기 때문에 스레드 작업(생성, 종료 등)이 일반적인 경우 더 많은 오버헤드가 발생한다는 것입니다.

커널 스레드는 일부 문제를 해결할 수 있지만 모든 문제를 해결하지는 않습니다. 예를 들어 멀티스레드 프로세스가 포크되면 어떻게 될까요? 새 프로세스에는 이전 프로세스와 동일한 수의 스레드가 있습니까, 아니면 단 하나의 스레드만 있습니까? 대부분의 경우 최선의 선택은 프로세스가 다음에 수행할 계획에 따라 달라집니다. 새 프로그램을 시작하기 위해 exec를 호출할 예정이라면 하나의 스레드를 선택하는 것이 맞을 수도 있지만 계속 실행된다면 모든 스레드를 다시 생성하는 것이 더 좋습니다.

또 다른 문제는 신호입니다. 적어도 클래식 모델에서는 신호가 스레드가 아닌 프로세스로 전송된다는 점을 기억하세요. 신호가 들어오면 어떤 스레드가 이를 처리해야 할까요? 스레드는 특정 신호에 대한 관심을 등록할 수 있으므로 신호가 들어오면 이를 원한다고 말한 스레드로 전송됩니다. 하지만 두 개 이상의 스레드가 동일한 신호에 등록되면 어떻게 될까요? 이것은 스레드가 소개하는 문제 중 두 가지에 불과하며, 더 많은 문제가 있습니다.

  • 하이브리드 구현

사용자 수준 스레드와 커널 수준 스레드의 장점을 결합하기 위해 다양한 접근 방식이 연구되었습니다. 한 가지 접근 방식은 아래 그림과 같이 커널 수준 스레드를 사용한 다음 사용자 수준 스레드를 일부 또는 전체에 다중화하는 것입니다. 이 접근 방식을 사용하면 개발자는 사용할 커널 스레드 수와 스레드당 재사용할 사용자 수준 스레드 수를 결정할 수 있습니다. 이 모델은 최대의 유연성을 제공합니다.

사용자 수준 스레드를 커널 수준 스레드로 다중화합니다.

이 접근 방식을 사용하면 커널은 커널 수준의 스레드만 알고 예약합니다. 이러한 스레드 중 일부에는 여러 사용자 수준 스레드가 다중화되어 있을 수 있으며, 이러한 사용자 수준 스레드는 멀티스레딩 기능 없이 운영 체제에서 실행되는 프로세스의 사용자 수준 스레드와 마찬가지로 생성, 삭제 및 예약됩니다. 이 모델에서 각 커널 수준 스레드에는 이를 차례로 사용하는 여러 사용자 수준 스레드가 있습니다.

18.5.5 멀티스레딩 단일 스레드 코드

기존의 많은 프로그램은 단일 스레드 프로세스용으로 작성되었으며 이를 다중 스레드로 변환하는 것은 처음에 나타나는 것보다 더 복잡합니다. 아래에서는 몇 가지 함정을 살펴보겠습니다.

우선, 스레드의 코드는 일반적으로 프로세스와 마찬가지로 여러 프로세스로 구성되며 이러한 변수에는 로컬 변수, 전역 변수 및 매개변수가 있을 수 있습니다. 지역 변수와 매개변수는 문제를 일으키지 않지만 스레드에 전역적이지만 전체 프로그램에 전역적이지 않은 변수는 문제가 됩니다. 이러한 변수는 스레드의 많은 프로세스가 이를 사용하기 때문에 전역 변수이지만(전역 변수를 사용할 수 있으므로) 다른 스레드는 논리적으로 이를 사용하지 않아야 합니다.

예를 들어, UNIX에서 유지 관리하는 errno 변수를 생각해 보십시오. 프로세스(또는 스레드)가 시스템 호출에 실패하면 오류 코드가 errno에 입력됩니다. 아래 다이어그램에서 스레드 1은 시스템 호출 액세스를 수행하여 특정 파일에 액세스할 수 있는 권한이 있는지 확인합니다. 운영 체제는 전역 변수 errno에 답을 반환합니다. 제어가 스레드 1로 반환된 후 errno를 읽을 기회가 생기기 전에 스케줄러는 스레드 1에 현재 충분한 CPU 시간이 있다고 판단하고 스레드 2로 전환하기로 결정합니다. 스레드 2는 실패한 열기 호출을 실행하여 errno를 덮어쓰게 되고 스레드 1의 액세스 코드가 영원히 손실됩니다. 나중에 스레드 1이 시작되면 잘못된 값을 읽고 동작이 올바르지 않습니다.

전역 변수 사용으로 인해 스레드 간 충돌이 발생합니다.

이 문제에 대한 해결책은 여러 가지가 있습니다. 하나는 전역 변수를 완전히 금지하는 것입니다. 이는 이 이상이 아무리 가치 있다고 해도 기존 소프트웨어와 충돌합니다. 또 다른 접근 방식은 아래 이미지에 표시된 것처럼 각 스레드에 고유한 전용 전역 변수를 할당하는 것입니다. 이런 방식으로 각 스레드는 errno 및 기타 전역 변수의 자체 개인 복사본을 가지므로 충돌을 피할 수 있습니다. 실제로 이 결정은 변수가 스레드의 모든 프로세스에 표시되지만 다른 스레드에는 표시되지 않는 새로운 범위 수준을 생성합니다. 또한 변수의 기존 범위 수준은 하나의 프로세스에서만 볼 수 있으며 변수는 프로그램의 모든 곳에서 볼 수 있습니다.

스레드에는 전용 전역 변수가 있을 수 있습니다.

그러나 대부분의 프로그래밍 언어에는 지역 변수와 전역 변수를 나타내는 방법이 있지만 그 사이에는 없기 때문에 개인 전역 변수에 액세스하는 것은 약간 까다롭습니다. 전역 변수에 메모리 조각을 할당하고 이를 스레드의 각 프로세스에 추가 매개변수로 전달할 수 있습니다. 우아한 접근 방식은 아니지만 작동합니다. 또는 이러한 스레드 범위 전역 변수를 생성, 설정 및 읽기 위해 새로운 라이브러리 프로시저를 도입할 수 있습니다. 첫 번째 호출은 다음과 같습니다.

cpp
create_global("bufptr");

힙이나 호출 스레드용으로 예약된 특수 저장 영역에 bufptr이라는 포인터에 대한 저장 공간을 할당합니다. 스토리지가 어디에 할당되든 호출 스레드만 전역 변수에 액세스할 수 있습니다. 다른 스레드가 동일한 이름의 전역 변수를 생성하면 기존 저장 위치와 충돌하지 않는 다른 저장 위치를 ​​갖게 됩니다. 전역 변수에 액세스하려면 두 번의 호출이 필요합니다. 하나는 쓰기용이고 다른 하나는 읽기용입니다. 작성 방법은 다음 코드와 유사합니다.

cpp
set_global("bufptr", &buf);

create 전역 호출로 이전에 생성된 저장 위치에 포인터 값을 저장합니다. 전역 변수를 읽으려면 호출이 다음과 같을 수 있습니다.

cpp
bufptr = read_global("bufptr");

전역 변수에 저장된 주소를 반환하므로 해당 데이터에 액세스할 수 있습니다.

단일 스레드 프로그램을 다중 스레드 프로그램으로 변환할 때 발생하는 다음 문제는 많은 라이브러리 프로시저가 재진입이 불가능하다는 것입니다. 즉, 이전 호출이 아직 완료되지 않은 동안에는 지정된 프로시저를 두 번째 호출하지 않습니다. 예를 들어, 네트워크를 통해 메시지를 보내는 것은 라이브러리 내의 고정 버퍼에 메시지를 모은 다음 커널에 트랩하여 메시지를 보내도록 프로그래밍될 가능성이 높습니다. 한 스레드가 자신의 메시지를 버퍼에 모은 다음 클록 인터럽트로 인해 자체 메시지로 버퍼를 즉시 덮어쓰는 두 번째 스레드로 강제 전환되면 어떻게 될까요?

마찬가지로, 메모리 할당 프로세스(예: UNIX의 malloc)는 사용 가능한 메모리 블록의 연결된 목록과 같은 메모리 사용량에 대한 주요 테이블을 유지 관리합니다. malloc이 이러한 목록을 업데이트하는 동안 포인터가 아무 곳도 가리키지 않아 일시적으로 일관성이 없는 상태에 있을 수 있습니다. 테이블이 일관성이 없을 때 스레드 전환이 발생하고 다른 스레드에서 새 호출이 발생하면 잘못된 포인터가 사용되어 프로그램이 중단될 수 있습니다. 이러한 모든 문제를 효율적으로 해결한다는 것은 전체 라이브러리를 다시 작성하는 것을 의미하며, 이는 미묘한 버그가 발생할 가능성이 있는 사소하지 않은 작업입니다.

또 다른 해결책은 라이브러리가 사용되는 것으로 표시하는 비트를 설정하는 재킷을 각 프로세스에 제공하는 것입니다. 이전 호출이 완료되지 않은 동안 다른 스레드가 라이브러리 프로시저를 사용하려는 시도는 차단됩니다. 이 접근 방식은 효과적이지만 잠재적인 병렬성을 크게 제거합니다.

다음으로 신호를 고려하십시오. 일부 신호는 논리적으로 스레드별로 특정하지만 다른 신호는 그렇지 않습니다. 예를 들어 스레드가 경고를 호출하면 결과 신호가 호출 스레드로 전송되어야 합니다. 그러나 스레드가 완전히 사용자 공간에서 구현되면 커널은 스레드에 대해 알지 못하므로 신호를 올바른 스레드로 보내기가 어렵습니다. 프로세스가 한 번에 하나의 경보만 보류할 수 있고 여러 스레드가 독립적으로 경보를 호출하는 경우 추가적인 문제가 발생합니다.

키보드 인터럽트와 같은 다른 신호는 스레드별로 다르지 않습니다. 누가 잡아야 할까요? 지정된 스레드? 모든 스레드? 새로 생성된 팝업 스레드? 또한 한 스레드가 다른 스레드에 알리지 않고 신호 처리기를 변경하면 어떻게 되나요? 한 스레드가 특정 신호(예: 사용자가 CTRL-C 누르기)를 포착하려고 하고 다른 스레드가 이 신호를 통해 프로세스를 종료하려는 경우 어떻게 됩니까? 이러한 상황은 하나 이상의 스레드가 표준 라이브러리 프로시저를 실행하고 다른 스레드는 사용자가 작성한 경우 발생할 수 있습니다. 분명히 이러한 기대는 양립할 수 없습니다. 일반적으로 신호는 단일 스레드 환경에서 관리하기 어렵고 다중 스레드 환경으로 전환해도 처리하기가 더 쉽지 않습니다.

스레드로 인해 발생하는 마지막 문제는 스택 관리입니다. 많은 시스템에서 프로세스의 스택이 오버플로되면 커널은 자동으로 프로세스에 더 많은 스택을 제공합니다. 프로세스에 스레드가 여러 개 있는 경우 스택도 여러 개 있어야 합니다. 커널은 이러한 모든 스택에 대해 알지 못한 채 스택 오류가 발생할 때 이를 자동으로 늘릴 수 없으며 실제로 메모리 오류가 일부 스레드 스택의 성장과 관련되어 있다는 사실조차 인식하지 못할 수도 있습니다.

이러한 문제는 확실히 극복할 수 없는 것은 아니지만 상당히 실질적인 시스템 재설계 없이 기존 시스템에 스레드를 도입하는 것만으로는 작동하지 않는다는 것을 보여줍니다. 최소한 시스템 호출의 의미를 재정의하고 라이브러리를 다시 작성해야 할 수도 있습니다. 이러한 모든 작업은 프로세스에 스레드가 하나만 있는 기존 프로그램과의 하위 호환성을 유지하는 방식으로 수행되어야 합니다.

18.5.6 Windows 스레드

프로세스는 관리되는 개체이며 코드를 직접 실행하지 않습니다. Windows에서 어떤 작업을 수행하려면 스레드를 만들어야 합니다. 사용자 모드 프로세스는 실행 파일의 기본 진입점을 최종적으로 실행하는 스레드에 의해 생성됩니다. 대부분의 경우 애플리케이션에는 더 많은 스레드가 필요하지 않을 수 있습니다. 그러나 일부 응용 프로그램에서는 여러 프로세스 내 실행 스레드를 사용하면 이점을 얻을 수 있습니다. 각 스레드는 독립적인 실행 경로이므로 서로 다른 프로세서를 사용하여 진정한 동시성을 달성할 수 있습니다.

스레드는 코드 실행의 실제 전달자이며 프로세스 내에 포함되어 있으며 프로세스에서 노출된 리소스(예: 가상 메모리 및 커널 개체에 대한 핸들)를 사용하여 작업합니다. 스레드가 갖고 있는 가장 중요한 속성은 다음과 같습니다.

  • 현재 액세스 모드, 사용자 또는 커널.

  • 프로세서 레지스터를 포함한 실행 컨텍스트.

  • 스택, 로컬 변수 할당 및 호출 관리에 사용됩니다.

  • 균일한 액세스 의미론으로 스레드 전용 데이터를 저장하는 방법을 제공하는 TLS(스레드 로컬 저장소) 배열.

  • 기본 우선순위 및 현재(동적) 우선순위.

  • 스레드가 실행될 수 있는 프로세서를 나타내는 프로세서 선호도.

스레드의 가장 일반적인 상태는 다음과 같습니다.

  • 달려요. 코드가 현재 (논리적) 프로세서에서 실행 중입니다.

  • 준비가 된. 모든 관련 프로세서가 사용 중이거나 사용할 수 없기 때문에 예약된 실행을 기다리는 중입니다.

  • 대기 중. 계속하기 전에 일부 이벤트가 발생할 때까지 기다리십시오. 이벤트가 발생한 후 스레드는 준비 상태로 이동합니다.

스레드는 실행 관점에서 동시에 활성화될 수 있는 다른 스레드와 독립적인 독립적인 실행 경로를 추상화합니다. 스레드가 실행을 시작하면 종료될 때까지 다음 중 하나를 수행할 수 있습니다.

  • CPU 집약적 작업 - 진행을 위해 CPU 작업에 의존하는 함수 계산 또는 호출입니다.

  • I/O 집약적 작업 - 디스크나 네트워크와 같은 I/O 장치에서 수행되는 작업입니다. I/O 작업이 완료되기를 기다리는 동안 스레드는 대기 상태에 있으며 CPU 주기를 소비하지 않습니다.

  • 동기화 프리미티브(뮤텍스 등)를 기다리는 등 스레드가 대기 상태에 들어갈 수 있는 기타 작업입니다.

스레드를 더 자세히 살펴보기 전에 스레드가 프로세서의 추상화라는 점을 인식해야 합니다. 그렇다면 프로세서의 정의는 정확히 무엇입니까? 여러 코어가 일반적인 CPU를 구성하는 시대에는 이러한 용어가 혼란스러울 수 있습니다. 아래 그림은 일반적인 CPU의 논리적 구성을 보여줍니다.

위 사진에는 컴퓨터 마더보드에 붙어 있는 물리적인 칩인 소켓(Socket)이 있습니다. 랩탑과 가정용 컴퓨터에는 단 한 대만 있는 경우가 많으며, 대형 서버 컴퓨터에는 여러 개의 슬롯이 있을 수 있습니다. 각 소켓에는 독립적인 프로세서인 여러 개의 코어가 있습니다(위 그림에서는 4개).

Intel 프로세서에서는 하드웨어 스레딩이라고도 알려진 하이퍼 스레딩이라는 기술로 인해 각 코어가 두 개의 논리 프로세서로 분할될 수 있습니다. Windows의 관점에서 프로세서 수는 논리 프로세서 수입니다. 아래 그림은 블로거의 노트북에 16개의 논리 프로세서가 있음을 보여줍니다. 이는 특정 순간에 최대 16개의 스레드가 실행되고 있음을 의미합니다. 작업 관리자에는 소켓, 코어 및 논리 프로세서 수도 표시됩니다.

AMD에는 **동시 멀티 스레딩(Simultaneous Multi Threading, SMT)**이라는 유사한 기술도 있습니다.

BIOS 설정에서 하이퍼스레딩을 비활성화할 수 있습니다. 하이퍼스레딩의 잠재적인 단점은 코어를 공유하는 두 개의 논리 프로세서가 L2 캐시도 공유하므로 서로 "간섭"할 수 있다는 것입니다.

18.5.6.1 포크-조인

다음 예에서는 멀티스레딩의 복잡한 사용법을 보여줍니다. PrimeSconter 애플리케이션은 지정된 수의 스레드를 사용하여 숫자 범위의 소수를 계산합니다. 아이디어는 작업을 여러 스레드로 분할하여 각 스레드가 숫자 범위의 소수를 계산하는 것입니다. 그런 다음 기본 스레드는 모든 작업자 스레드가 종료될 때까지 기다리므로 모든 스레드의 수를 간단히 합산할 수 있습니다. 아래 그림과 같습니다.

일부 작업을 수행하는 여러 스레드를 생성하고 결과를 집계하기 전에 스레드가 종료될 때까지 기다리는 이러한 아이디어를 포크-조인(Fork-Join)이라고도 합니다. 스레드가 일부 초기 스레드에서 "포크"된 다음 완료되면 초기 스레드에 "다시 조인"하기 때문입니다.

이 패턴의 또 다른 이름은 구조적 병렬성입니다.

이 애플리케이션에 사용되는 스레드 수는 알고리즘의 매개변수 중 하나입니다. 흥미로운 질문은 계산을 가장 빨리 완료하기 위한 최적의 스레드 수는 얼마입니까?입니다.

다음 코드는 위에 표시된 PrimeSconter 애플리케이션의 구현 코드입니다.

cpp
struct PrimesData 
{
    int From, To;
    int Count;
};

bool IsPrime(int n) 
{
    if (n < 2)
        return false;
    if(n == 2)
        return true;
    
    int limit = (int)::sqrt(n);
    for (int i = 2; i <= limit; i++)
        if (n % i == 0)
            return false;
    
    return true;
}

DWORD WINAPI CalcPrimes(PVOID param) 
{
    auto data = static_cast<PrimesData*>(param);
    int from = data->From, to = data->To;
    int count = 0;
    for (int i = from; i <= to; i++)
        if (IsPrime(i))
            count++;
        data->Count = count;
    
    return count;
}
    
int CalcAllPrimes(int from, int to, int threads, DWORD& elapsed) 
{
    auto start = ::GetTickCount64();
    // allocate data for each thread
    auto data = std::make_unique<PrimesData[]>(threads);
    // allocate an array of handles
    auto handles = std::make_unique<HANDLE[]>(threads);
    int chunk = (to - from + 1) / threads;
    for (int i = 0; i < threads; i++) 
    {
        auto& d = data[i];
        d.From = i * chunk;
        d.To = i == threads - 1 ? to : (i + 1) * chunk - 1;
        DWORD tid;
        handles[i] = ::CreateThread(nullptr, 0, CalcPrimes, &d, 0, &tid);
        assert(handles[i]);
        printf("Thread %d created. TID=%u\n", i + 1, tid);
    }
    
    elapsed = static_cast<DWORD>(::GetTickCount64() - start);
    FILETIME dummy, kernel, user;
    
    int total = 0;
    for (int i = 0; i < threads; i++) 
    {
        ::GetThreadTimes(handles[i], &dummy, &dummy, &kernel, &user);
        int count = data[i].Count;
        printf("Thread %2d Count: %7d. Execution time: %4u msec\n", i + 1, count, (user.dwLowDateTime + kernel.dwLowDateTime) / 10000);
        total += count;
        ::CloseHandle(handles[i]);
    }
    
    return total;
}

int main(int argc, const char* argv[]) 
{
    if (argc < 4) 
    {
        printf("Usage: PrimesCounter <from> <to> <threads>\n");
        return 0;
    }
    
    int from = atoi(argv[1]);
    int to = atoi(argv[2]);
    int threads = atoi(argv[3]);
    if (from < 1 || to < 1 || threads < 1 || threads > 64) 
    {
        printf("Invalid input.\n");
        return 1;
    }
    
    DWORD elapsed;
    int count = CalcAllPrimes(from, to, threads, elapsed);
    printf("Total primes: %d. Elapsed: %d msec\n", count, elapsed);
    
    return 1;
}

다음은 하나의 스레드를 사용하여 기준선에서 시작하여 동일한 값 범위에 대한 일부 실행입니다.

cpp
C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 1
Thread 1 created (3 to 20000000). TID=29760
Thread 1 Count: 1270606. Execution time: 9218 msec
Total primes: 1270606. Elapsed: 9218 msec

C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 2
Thread 1 created (3 to 10000001). TID=22824
Thread 2 created (10000002 to 20000000). TID=41816
Thread 1 Count: 664578. Execution time: 3625 msec
Thread 2 Count: 606028. Execution time: 5968 msec
Total primes: 1270606. Elapsed: 5984 msec

C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 4
Thread 1 created (3 to 5000001). TID=52384
Thread 2 created (5000002 to 10000000). TID=47756
Thread 3 created (10000001 to 14999999). TID=42296
Thread 4 created (15000000 to 20000000). TID=34972
Thread 1 Count: 348512. Execution time: 1312 msec
Thread 2 Count: 316066. Execution time: 2218 msec
Thread 3 Count: 306125. Execution time: 2734 msec
Thread 4 Count: 299903. Execution time: 3140 msec
Total primes: 1270606. Elapsed: 3141 msec

C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 8
Thread 1 created (3 to 2500001). TID=25200
Thread 2 created (2500002 to 5000000). TID=48588
Thread 3 created (5000001 to 7499999). TID=52904
Thread 4 created (7500000 to 9999998). TID=18040
Thread 5 created (9999999 to 12499997). TID=50340
Thread 6 created (12499998 to 14999996). TID=43408
Thread 7 created (14999997 to 17499995). TID=53376
Thread 8 created (17499996 to 20000000). TID=33848
Thread 1 Count: 183071. Execution time: 578 msec
Thread 2 Count: 165441. Execution time: 921 msec
Thread 3 Count: 159748. Execution time: 1171 msec
Thread 4 Count: 156318. Execution time: 1343 msec
Thread 5 Count: 154123. Execution time: 1531 msec
Thread 6 Count: 152002. Execution time: 1531 msec
Thread 7 Count: 150684. Execution time: 1718 msec
Thread 8 Count: 149219. Execution time: 1765 msec
Total primes: 1270606. Elapsed: 1766 msec

C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 16
Thread 1 created (3 to 1250001). TID=50844
Thread 2 created (1250002 to 2500000). TID=9792
Thread 3 created (2500001 to 3749999). TID=12600
Thread 4 created (3750000 to 4999998). TID=52804
Thread 5 created (4999999 to 6249997). TID=5408
Thread 6 created (6249998 to 7499996). TID=42488
Thread 7 created (7499997 to 8749995). TID=49336
Thread 8 created (8749996 to 9999994). TID=13384
Thread 9 created (9999995 to 11249993). TID=41508
Thread 10 created (11249994 to 12499992). TID=12900
Thread 11 created (12499993 to 13749991). TID=39512
Thread 12 created (13749992 to 14999990). TID=3084
Thread 13 created (14999991 to 16249989). TID=52760
Thread 14 created (16249990 to 17499988). TID=17496
Thread 15 created (17499989 to 18749987). TID=39956
Thread 16 created (18749988 to 20000000). TID=31672
Thread 1 Count: 96468. Execution time: 281 msec
Thread 2 Count: 86603. Execution time: 484 msec
Thread 3 Count: 83645. Execution time: 562 msec
Thread 4 Count: 81795. Execution time: 671 msec
Thread 5 Count: 80304. Execution time: 781 msec
Thread 6 Count: 79445. Execution time: 812 msec
Thread 7 Count: 78589. Execution time: 859 msec
Thread 8 Count: 77729. Execution time: 828 msec
Thread 9 Count: 77362. Execution time: 906 msec
Thread 10 Count: 76761. Execution time: 1000 msec
Thread 11 Count: 76174. Execution time: 984 msec
Thread 12 Count: 75828. Execution time: 1046 msec
Thread 13 Count: 75448. Execution time: 1078 msec
Thread 14 Count: 75235. Execution time: 1062 msec
Thread 15 Count: 74745. Execution time: 1062 msec
Thread 16 Count: 74475. Execution time: 1109 msec
Total primes: 1270606. Elapsed: 1188 msec

C:\Dev\Win10SysProg\x64\Release>PrimesCounter.exe 3 20000000 20
Thread 1 created (3 to 1000001). TID=30496
Thread 2 created (1000002 to 2000000). TID=7300
Thread 3 created (2000001 to 2999999). TID=50580
Thread 4 created (3000000 to 3999998). TID=21536
Thread 5 created (3999999 to 4999997). TID=24664
Thread 6 created (4999998 to 5999996). TID=34464
Thread 7 created (5999997 to 6999995). TID=51124
Thread 8 created (6999996 to 7999994). TID=29972
Thread 9 created (7999995 to 8999993). TID=50092
Thread 10 created (8999994 to 9999992). TID=49396
Thread 11 created (9999993 to 10999991). TID=18264
Thread 12 created (10999992 to 11999990). TID=33496
Thread 13 created (11999991 to 12999989). TID=16924
Thread 14 created (12999990 to 13999988). TID=44692
Thread 15 created (13999989 to 14999987). TID=53132
Thread 16 created (14999988 to 15999986). TID=53692
Thread 17 created (15999987 to 16999985). TID=5848
Thread 18 created (16999986 to 17999984). TID=12760
Thread 19 created (17999985 to 18999983). TID=13180
Thread 20 created (18999984 to 20000000). TID=49980
Thread 1 Count: 78497. Execution time: 218 msec
Thread 2 Count: 70435. Execution time: 343 msec
Thread 3 Count: 67883. Execution time: 421 msec
Thread 4 Count: 66330. Execution time: 484 msec
Thread 5 Count: 65366. Execution time: 578 msec
Thread 6 Count: 64337. Execution time: 640 msec
Thread 7 Count: 63798. Execution time: 640 msec
Thread 8 Count: 63130. Execution time: 703 msec
Thread 9 Count: 62712. Execution time: 718 msec
Thread 10 Count: 62090. Execution time: 703 msec
Thread 11 Count: 61937. Execution time: 781 msec
Thread 12 Count: 61544. Execution time: 812 msec
Thread 13 Count: 61191. Execution time: 796 msec
Thread 14 Count: 60826. Execution time: 843 msec
Thread 15 Count: 60627. Execution time: 875 msec
Thread 16 Count: 60425. Execution time: 875 msec
Thread 17 Count: 60184. Execution time: 875 msec
Thread 18 Count: 60053. Execution time: 890 msec
Thread 19 Count: 59681. Execution time: 875 msec
Thread 20 Count: 59560. Execution time: 906 msec
Total primes: 1270606. Elapsed: 1109 msec

이러한 실행을 수행하는 시스템에는 16개의 논리 프로세서가 있습니다. 다음은 위 출력에서 몇 가지 흥미로운 관찰 결과입니다.

  • 실행 시간 향상은 스레드 수가 증가함에 따라 선형적이지 않습니다(가까우지도 않음).

  • 논리 프로세서 수보다 많은 스레드를 사용하면 실행 시간이 단축됩니다.

왜 이런 결과가 나오는 걸까요? Fork-Join 알고리즘에서 최적의 스레드 수는 얼마입니까? 대답은 "논리적 프로세서의 수"여야 합니다. 왜냐하면 모든 스레드가 동시에 실행될 수 없기 때문에 점점 더 많은 스레드가 컨텍스트 전환을 일으키고 더 적은 스레드를 사용하면 확실히 일부 프로세서를 사용할 수 없게 되기 때문입니다.

그러나 실제로는 그렇게 간단하지 않습니다. 이 두 가지 관찰의 유일한 이유는 스레드 간의 작업 분배가 동일하지 않기 때문입니다(실행 시간 측면에서). 단순히 사용된 알고리즘 때문입니다. 숫자가 클수록 더 많은 작업을 수행해야 합니다. sqrt 함수는 단조 함수이고 출력은 입력에 비례하기 때문입니다. **일반적으로 Fork-Join 알고리즘의 문제점: 공정한 작업 분할.**아래 그림은 4개의 스레드가 있는 상황의 예를 보여줍니다.

위의 출력에서 나중 스레드는 수행할 작업이 더 많기 때문에 더 오래 실행됩니다. 분명히 시스템에 논리 프로세서가 16개만 있어도 20개의 스레드를 사용하면 더 나은 런타임을 얻을 수 있습니다. 완료된 초기 스레드는 프로세서를 유휴 상태로 유지하여 해당 "추가" 스레드(16개 이후)가 프로세서를 확보할 수 있도록 하여 작업을 진행시킵니다. 제한사항이 있나요? 물론 과도한 스레드 스택 메모리 할당으로 인해 발생할 수 있는 페이지 오류와 컨텍스트 전환 오버헤드가 어느 정도 결합되어 상황을 더욱 악화시킬 수 있습니다. 분명히 애플리케이션에 대한 최적의 프로세서 수를 결정하는 것은 쉬운 문제가 아닙니다. 더욱 대답하기 어려운 점은 스레드가 I/O를 자주 수행해야 하는 경우 이 질문이 더욱 어려워진다는 것입니다.

18.5.6.2 스레드 종료

모든 좋은(또는 나쁜) 스레드는 어느 시점에서 끝나야 합니다. Windows 스레드를 종료하는 방법에는 세 가지가 있습니다.

  1. 스레드 함수가 반환됩니다(최상의 옵션).

  2. 스레드가 ExitThread를 호출합니다(피하는 것이 가장 좋음).

  3. 스레드는 TerminateThread로 종료됩니다(대개 나쁜 생각입니다).

18.5.6.3 스레드 스택

함수의 지역 변수와 반환 주소는 스레드 스택에 있습니다. 스레드 스택의 크기는 CreateThread의 두 번째 매개변수를 통해 지정할 수 있지만 실제로 스레드 스택에 영향을 미치는 두 가지 값이 있습니다. 스택의 최대 크기인 예약된 메모리 크기와 사용하기 위해 준비된 초기 커밋된 메모리 크기입니다. 예약된 메모리는 단순히 연속된 주소 공간 범위를 특정 목적으로 사용되는 것으로 표시하므로 프로세스 주소 공간의 새로운 할당은 해당 범위에서 이루어지지 않습니다. 스택의 경우 스택이 항상 연속되어 있기 때문에 이는 필요합니다. 커밋된 메모리는 실제로 할당되어 사용할 수 있는 메모리를 의미합니다.

전체 스택을 미리 커밋하여 최대 스택 크기를 즉시 할당하는 것도 가능하지만 스레드가 스택 관련 작업을 수행하는 데 전체 범위가 필요하지 않을 수 있으므로 이는 낭비입니다. 메모리 관리자에는 최적화 체계가 있습니다. 더 적은 양의 메모리를 커밋하고 스택이 해당 양을 초과하면 보존 제한에 도달할 때까지 스택 확장을 트리거합니다. 트리거링은 특수 플래그 PAGE_GUARD가 있는 페이지에 의해 수행되며, 이 페이지를 읽거나 쓰면 예외가 발생합니다. 메모리 관리자는 이 예외를 포착하고 추가 페이지를 커밋하여 PAGE_GUARD 페이지를 한 페이지 아래로 이동합니다(스택이 더 낮은 주소로 증가한다는 점을 기억하세요). 아래 이미지는 이러한 배열을 보여줍니다.

가드 페이지의 실제 최소값은 12KB, 즉 3페이지이며, 스택 확장을 통해 스택에 최소 12KB의 커밋된 메모리를 사용할 수 있도록 보장합니다.

Visual Studio에서는 PE 헤더에서 요청된 값을 설정하기만 하면 링커/시스템 노드(아래 그림) 아래의 프로젝트 속성을 사용하여 기본 스택 크기를 변경할 수 있습니다.

18.5.7 Windows 스레드 스케줄링

18.5.7.1 우선순위

각 스레드에는 연관된 우선순위가 있으며 이는 프로세서보다 스레드 수가 더 많은 경우 중요합니다.

스레드 우선순위 범위는 0부터 31까지이며 31이 가장 높습니다. 스레드 0은 커널 메모리 관리자의 일부이며 우선순위 0을 가질 수 있는 유일한 스레드인 제로 페이지 스레드라는 특수 스레드용으로 예약되어 있습니다. 사용자 모드에서는 우선순위를 어떤 값으로도 설정할 수 없습니다. 대신, 스레드의 우선순위는 프로세스 우선순위 클래스(작업 관리자에서는 기본 우선순위라고 함)와 해당 기본 우선순위에 대한 오프셋의 조합입니다.

Windows 스레드는 다음 API를 사용하여 스레드 우선순위를 설정할 수 있습니다.

cpp
BOOL SetThreadPriority(_In_ HANDLE hThread, _In_ int nPriority);

nPriority는 절대 우선순위가 아니라 상대적 우선순위입니다. 설명은 다음과 같습니다.

우선순위 값 효과

THREAD_PRIORITY_IDLE (-15) 우선순위가 1로 떨어졌고(실시간 우선순위 클래스 제외), 스레드 우선순위가 16으로 떨어졌습니다.

THREAD_PRIORITY_LOWSET (-2) 우선순위 클래스에 비해 우선순위가 2만큼 떨어졌습니다.

THREAD_PRIORITY_BELOW_NORMAL (-1) 우선순위 클래스에 비해 우선순위가 1만큼 떨어졌습니다.

THREAD_PRIORITY_NORMAL (0) 우선순위는 프로세스 우선순위 클래스 값으로 설정됩니다.

THREAD_PRIORITY_ABOVE_NORMAL (1) 우선순위 카테고리에 비해 우선순위가 1씩 증가합니다.

THREAD_PRIORITY_HIGHEST (2) 우선순위 카테고리에 비해 우선순위가 2만큼 증가합니다.

THREAD_PRIORITY_TIME_CRITICAL (15) 스레드 우선순위가 31로 증가되는 실시간 우선순위 클래스를 제외하고 우선순위는 15로 증가됩니다.

아래 이미지에서 각 직사각형은 SetPriorityClass/SetThreadPriority를 기반으로 가능한 스레드 우선순위 값을 나타냅니다.

다음 표는 우선순위별 스레드 우선순위입니다.

)

Windows 우선순위 관계 예:

18.5.7.2 단일 CPU 스케줄링

예약은 다중 프로세서, 전원 관리(한쪽에서는 전원을 절약하고 다른 쪽에서는 모든 프로세서를 활용), NUMA(Non-Uniform Memory Architecture), 하이퍼 스레딩, 캐싱 등 여러 요소를 고려하여 서로 충돌하는 매우 복잡한 경우가 많습니다. 정확한 예약 알고리즘이 문서화되지 않은 이유가 있습니다. Microsoft는 후속 Windows 버전 및 업데이트에서 이를 수정하고 조정할 수 있으며 개발자는 정확한 알고리즘에 의존할 필요가 없습니다. 하지만 실험을 통해 경험할 수 있는 스케줄링 알고리즘은 많습니다. 가장 간단한 종류의 스케줄링부터 시작하겠습니다. 즉, 시스템에 프로세서가 하나만 있는 경우 작업 스케줄링의 기초가 되기 때문입니다. 나중에 이러한 알고리즘이 다중 처리 시스템에서 어떻게 달라지는지 몇 가지 방법을 살펴보겠습니다.

스케줄러는 실행을 위해 스레드를 관리하는 준비 대기열을 유지 관리합니다(준비 상태). 현재 실행을 원하지 않는(대기 상태에 있는) 다른 모든 스레드는 실행을 원하지 않기 때문에 검토되지 않습니다. 아래 그림은 준비 상태의 7개 스레드가 우선순위에 따라 여러 대기열로 배열된 예시 시스템을 보여줍니다.

시스템에는 수천 개의 스레드가 있을 수 있지만 대부분은 대기 상태이므로 스케줄러는 이러한 대기 상태 스레드를 고려하지 않습니다.

단일 CPU 스케줄링 알고리즘은 아래에 설명되어 있습니다.

우선순위가 가장 높은 스레드가 먼저 실행됩니다. 위 다이어그램의 스레드 1과 스레드 2는 가장 높은(동일한) 우선순위(31)를 가지므로 대기열에서 우선순위 31을 가진 첫 번째 스레드가 실행됩니다. 스레드 1(아래 그림)이라고 가정해 보겠습니다.

스레드 1은 Quantum이라는 일정 기간 동안 실행됩니다. 스레드 1에 할 일이 많다고 가정해 보겠습니다. 해당 시간이 만료되면 스케줄러는 스레드 1을 선점하고 해당 상태를 커널 스택에 저장한 다음 준비 상태로 돌아갑니다(아직 할 일이 있으므로). 스레드 2는 이제 동일한 우선순위를 가지므로 실행 중인 스레드가 됩니다(아래 이미지).

따라서 우선순위가 결정 요인입니다. 스레드 1과 스레드 2가 실행되어야 하는 한, 이들은 CPU의 루프에서 실행되며 각 스레드는 잠시 동안 실행됩니다. 다행히 스레드는 일반적으로 영원히 실행되지 않습니다. 대신 어느 시점에서 대기 상태로 들어갑니다. 다음은 스레드가 대기 상태에 들어가는 원인에 대한 몇 가지 예입니다.

  • 동기식 I/O 작업을 수행합니다.

  • 현재 신호를 받지 않은 커널 개체를 기다립니다.

  • UI 메시지가 없을 때 UI 메시지를 기다립니다.

  • 자발적인 수면을 입력합니다.

스레드가 대기 상태에 들어가면 스케줄러의 준비 대기열에서 제거됩니다. 스레드 1과 스레드 2가 대기 상태에 들어간다고 가정합니다. 이제 우선순위가 가장 높은 스레드는 스레드 3이며, 이는 실행 스레드가 됩니다(아래 이미지).

스레드 3은 양자를 실행합니다. 아직 할 일이 남아 있다면 우선 순위가 유일한 것이기 때문에 또 다른 양자를 얻습니다. 그러나 스레드 1이 기다리고 있는 것을 수신하면 준비 상태로 들어가 스레드 3(스레드 1의 우선순위가 더 높으므로)을 선점하고 실행 스레드가 됩니다. 스레드 3이 준비 상태로 돌아갑니다(아래 이미지). 이 스위치는 스레드 3의 퀀텀이 끝나는 시점이 아니라 변경 시(스레드 1의 대기가 완료됨) 발생합니다. 스레드 3의 우선순위가 15보다 높으면 스레드 3의 퀀텀이 보충됩니다.

알고리즘을 고려하면 준비 상태에서 우선순위가 더 높은 스레드가 없으면 스레드 4, 5, 6은 각각 고유한 실행량을 갖습니다.

위 내용은 일정의 기본입니다. 실제로 실제 단일 CPU 시나리오에서는 이것이 바로 사용되는 알고리즘입니다. 그러나 이 경우에도 Windows는 어느 정도 "공정"하려고 노력합니다. 예를 들어, 위 다이어그램의 스레드 7(우선순위 4)은 우선순위가 더 높은 스레드가 준비 상태인 경우 실행되지 않을 수 있으므로 CPU 부족으로 인해 영향을 받습니다. 그러한 시스템에서 이 스레드는 실패할 운명인가요? 물론 그렇지 않습니다. 시스템은 이 스레드의 우선순위를 4초마다 약 15배로 증가시켜 앞으로 나아갈 가능성을 더 높여줍니다. 이 승격은 스레드가 실제로 실행되는 동안 일정 기간 동안 지속된 다음 우선순위가 초기 값으로 다시 떨어집니다.

18.5.7.3 범위

Quantum은 위에서 몇 번 언급했는데 범위는 얼마나 됩니까? 스케줄러는 두 가지 직교 방식으로 작동합니다. 첫 번째는 기본적으로 15.625밀리초마다 실행되는 타이머를 사용합니다. 이 타이머는 GetSystemTimeAdjustment를 호출하고 두 번째 인수를 확인하여 얻을 수 있습니다. 또 다른 방법은 SysInternals의 clockres 도구를 사용하는 것입니다.

cpp
C:\Users\pavel>clockres

기본 시간은 클라이언트 컴퓨터(가정, 전문가, 기업, XBOX 등)의 경우 2시계 틱이고 서버 컴퓨터의 경우 12시계 틱입니다. 즉, 클라이언트의 경우 시간은 31.25밀리초이고 서버의 경우 187.5밀리초입니다.

서버 버전의 시간이 길어지는 이유는 단일 시간 내에 클라이언트 요청을 완전히 처리할 가능성이 높아지기 때문입니다. 클라이언트 시스템에서는 프로세스가 많고 각각 상대적으로 적은 작업을 수행할 수 있으며 일부 UI는 반응성이 있어야 하므로 짧은 숫자가 더 적합하기 때문에 이는 덜 중요합니다.

예약 클래스 값(0에서 9 사이)은 다음과 같은 방식으로 작업의 일부인 프로세스의 스레드 퀀텀을 설정합니다.

cpp
Quantum = 2 * (TimerInterval) * (SchedulingClass + 1);

18.5.7.4 프로세서 그룹

원래 Windows NT 디자인은 최대 32개의 프로세서를 지원했으며, 하나의 기계어(32비트)는 시스템의 실제 프로세서를 나타내는 데 사용되었으며 각 비트는 프로세서를 나타냅니다. 64비트 창이 등장하자 최대 프로세서 수는 자연스럽게 64개로 확장됐다.

Windows 7(64비트 시스템만 해당)부터 Microsoft는 64개 이상의 프로세서를 지원하기를 희망하므로 프로세서 그룹이라는 추가 매개변수가 나타납니다. 예를 들어 Windows 7 및 Server 2008 R2는 최대 256개의 프로세서를 지원합니다. 즉, 256개의 프로세서가 있는 시스템에는 4개의 프로세서 그룹이 있습니다.

스레드는 프로세서 그룹의 구성원이 될 수 있습니다. 즉, 현재 그룹에 속한 최대 64개의 프로세서 중 하나에서 스레드를 예약할 수 있습니다. 프로세스가 생성되면 프로세스가 그룹 전체에 "로드 밸런싱"될 수 있도록 라운드 로빈 방식으로 프로세서 그룹이 할당됩니다. 프로세스의 스레드는 프로세스 그룹에 할당되며 상위 프로세스는 다음 방법 중 하나로 하위 프로세스의 초기 프로세서 그룹에 영향을 미칠 수 있습니다.

  1. 상위 프로세스는 프로세스를 생성할 때 INHERIT_PARENT_AFFINITY 플래그를 플래그 중 하나로 사용하여 하위 프로세스가 시스템 관리 주기를 기반으로 상위 프로세서 그룹을 획득하는 대신 상위 프로세서 그룹을 상속해야 함을 나타낼 수 있습니다. 상위 프로세스의 스레드가 둘 이상의 선호도 그룹을 사용하는 경우 그룹 중 하나가 하위 프로세스 그룹에 사용할 그룹으로 임의로 선택됩니다.

  2. 상위 프로세스는 PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY 프로세스 속성을 사용하여 원하는 기본 프로세서 그룹을 지정할 수 있습니다.

18.5.7.5 다중 CPU 스케줄링

다중 프로세서 스케줄링은 스케줄링 알고리즘의 복잡성을 증가시킵니다. Windows가 보장하는 유일한 것은 실행할 우선 순위가 가장 높은 스레드(스레드가 여러 개인 경우 적어도 하나)가 현재 실행 중이라는 것입니다.

일반적으로 스레드는 모든 프로세서에서 예약될 수 있습니다. 그러나 스레드의 선호도, 즉 스레드가 실행될 수 있는 프로세서는 다양한 방법으로 제어할 수 있습니다.

이상적인 프로세서는 Soft Affinity라고도 불리는 스레드의 속성입니다. 이상적인 프로세서는 다른 모든 조건이 동일할 때 해당 스레드의 코드를 실행하는 데 선호되는 프로세서라는 것을 스케줄러에 암시하는 역할을 합니다. 기본 이상적인 프로세서는 프로세스가 생성될 때 생성된 임의의 숫자부터 시작하여 라운드 로빈 방식으로 선택됩니다. 하이퍼 스레드 시스템에서 다음 이상적인 프로세서는 다음 논리 프로세서가 아닌 다음 코어에서 선택됩니다. 이상적인 프로세서는 프로세스 탐색기 도구를 사용하여 스레드 탭(아래 이미지)에 표시된 속성 중 하나로 볼 수 있습니다.

이상적인 프로세서는 스레드가 실행되어야 하는 프로세서에 대한 힌트와 제안 역할을 하지만, 선호도라고도 불리는 하드 선호도를 사용하면 특정 스레드나 프로세스가 실행되도록 허용되는 프로세서를 지정할 수 있습니다. 하드 선호도는 프로세스와 스레드의 두 가지 수준에서 작동합니다. 여기서 기본 규칙은 스레드가 해당 프로세스에서 설정한 선호도를 벗어날 수 없다는 것입니다.

일반적으로 하드 선호도 제약 조건을 설정하는 것은 프로세서 할당 시 스케줄러의 자유를 제한하고 잠재적으로 스레드가 하드 선호도 제약 조건을 적용하지 않을 때보다 적은 CPU 시간을 확보하게 하므로 좋지 않은 생각입니다. 그러나 동일한 프로세서 세트에서 실행되는 스레드가 더 나은 CPU 캐시 활용도를 얻을 가능성이 높기 때문에 드문 경우에 유용할 수 있습니다. 아무 것도 실행하는 임의의 시스템이 아닌 특정 알려진 프로세스를 실행하는 시스템에 유용합니다. 하드 선호도의 또 다른 용도는 특정 실행에 더 적은 수의 프로세서를 사용하여 동일한 프로세스를 실행할 때 제한된 수의 프로세서가 있는 시스템이 어떻게 작동하는지 확인하는 스트레스 테스트입니다.

스레드의 선호도는 해당 프로세스 선호도를 "탈출"할 수 없습니다. 그러나 어떤 경우에는 하나의 스레드(또는 스레드)가 프로세스의 다른 스레드에서 사용이 금지된 프로세서를 사용하도록 하는 것이 좋습니다. Windows 10 및 Server 2016에는 CPU 세트라는 이 기능이 추가되었습니다.

CPU 세트는 프로세서의 추상적 뷰를 나타내며, 각 CPU 세트는 하나 이상의 논리 프로세서에 매핑될 수 있습니다. 그러나 현재 각 CPU 세트는 단일 논리 프로세서에 정확하게 매핑됩니다. 시스템에는 기본적으로 시스템의 모든 프로세서를 포함하는 자체 CPU 세트가 있습니다.

CPU 세트와 하드 유사성은 서로 충돌할 수 있습니다. 이 경우 하드 선호도가 항상 승리합니다. 즉, CPU 세트가 무시됩니다.

멀티프로세서(MP) 예약은 복잡하며 엄격한 친화성, 이상적인 프로세서, CPU 세트, 전력 고려 사항, 게임 모드 및 기타 측면을 포함합니다. MP 시스템에서 각 프로세서에는 자체 준비 대기열이 있습니다. 또한 Windows 8 이상에서는 프로세서 그룹에 공유 준비 대기열(현재 그룹당 최대 4개)이 있으므로 공유 준비 대기열에 연결된 준비 스레드에 대한 프로세서를 찾아야 할 때 스케줄러가 더 많은 옵션을 가질 수 있습니다(CPU별 준비 대기열은 여전히 ​​하드 선호도가 있는 스레드에 사용됩니다). 아래 그림은 개선되고 단순화된 MP 스케줄링 알고리즘을 보여줍니다. 선호도나 CPU 세트 제약 조건, 전력 또는 기타 특별한 고려 사항이 없다고 가정합니다.

위 다이어그램에 표시된 것처럼 이상적인 프로세서는 첫 번째 프로세서이고 그 다음은 실행된 마지막 프로세서입니다(프로세서의 캐시에는 해당 스레드에서 사용하는 데이터가 여전히 포함될 수 있음). 모든 프로세서가 사용 중인 경우 스케줄러는 우선순위가 낮은 스레드를 실행하는 첫 번째 프로세서를 선점하지 않습니다. 많은 프로세서를 검색해야 할 수 있으므로 이는 비효율적입니다. 대신 스레드는 이상적인 프로세서의 (공유) 준비 대기열에 배치됩니다.

Windows 시스템에서는 Windows 성능 레코더(wprui.exe) 및 Windows 성능 분석기(WPA)를 사용하여 각 스레드의 일정을 분석, 추적 및 모니터링할 수 있습니다(아래 그림).

18.5.7.6 배경 모드

일부 프로세스는 자연스럽게 다른 프로세스보다 더 중요합니다. 예를 들어, 사용자가 Microsoft Word를 사용하는 경우 Word의 상호 작용 및 사용이 매우 원활해지기를 원할 수 있습니다. 반면, 백업 애플리케이션, 바이러스 백신 스캐너, 검색 인덱서 등과 같은 프로세스는 중요하지 않으며 사용자의 기본 애플리케이션을 방해해서는 안 됩니다.

이러한 백그라운드 응용 프로그램이 영향을 제한하는 한 가지 방법은 CPU 우선 순위를 낮추는 것입니다. 이것이 작동하는 동안 CPU는 메모리 및 I/O를 포함한 다른 리소스와 함께 프로세스에서 사용되는 하나의 리소스일 뿐입니다. 즉, 스레드의 CPU 우선 순위나 프로세스의 우선 순위 수준을 낮추는 것만으로는 이러한 프로세스의 영향을 줄이는 데 충분하지 않을 수 있습니다.

Windows에서는 스레드의 CPU 우선순위가 4로 낮아지고 메모리 우선순위와 I/O 우선순위도 낮아지는 백그라운드 모드 개념을 제공합니다. 예를 들어 스레드 보기의 프로세스 탐색기에서 Windows 탐색기를 보면 메모리 및 I/O 우선 순위는 물론 CPU 우선 순위도 표시됩니다(아래 이미지). I/O 우선순위의 기본값은 "normal"이고 메모리 우선순위의 기본값은 5입니다(가능한 값은 0~7입니다).

18.5.7.7 우선순위 개선

우선순위는 일정을 결정하는 요소입니다. 그러나 Windows에서는 우선 순위 향상이라고 하는 우선 순위를 일부 조정합니다. 이러한 일시적인 우선순위 증가는 일정을 보다 "공정하게" 만들거나 사용자에게 더 나은 경험을 제공하기 위한 것입니다.

스레드가 동기 I/O 작업을 실행하면 작업이 완료될 때까지 대기 상태가 됩니다. 일단 완료되면 I/O 작업을 담당하는 장치 드라이버는 요청 스레드의 우선 순위를 높여 작업이 결국 완료될 때 속도를 높일 수 있습니다. 우선 순위 높이기(적용된 경우)는 드라이버가 결정한 양만큼 스레드의 우선 순위를 높이고, 우선 순위가 기본 수준으로 떨어질 때까지 스레드 관리가 실행될 때마다 우선 순위가 한 수준씩 감소합니다. 아래 그림은 프로세스의 개념적인 모습을 보여줍니다.

또한 우선순위 증가는 포그라운드 프로세스, GUI 스레드 깨우기, 기아 방지 규칙 등에 의해 트리거됩니다. 그러나 우선순위 증가는 향후 Microsoft에서 포기할 수 있으므로 사용을 피해야 합니다. 프로세스나 스레드는 일시 중지, 계속 또는 휴면 상태로 되어 다양한 세부 수준으로 일정 동작을 수행할 수도 있습니다.

18.6 동기화 메커니즘

이 장에서는 스레드와 프로세스 간의 통신, 경쟁 조건, 중요 섹션, 상호 배제, 하드웨어 솔루션, 엄격한 교대, Peterson 솔루션, 생산자-소비자 문제, 세마포어, 이벤트 카운터, 모니터, 메시지 전달 및 고전적인 IPC 문제뿐만 아니라 정의, 특성, 예방, 교착 상태 방지 등을 다룹니다.

성능을 향상시키기 위해 다중 스레드 동시성을 사용하는 방법에는 두 가지가 있습니다.

  • **작업 병렬성.**단일 작업을 여러 부분으로 나누고 병렬로 실행하여 총 실행 시간을 줄입니다. 이 방법은 간단하고 직관적인 것처럼 보이지만 실제로는 여러 부분 사이에 종속성이 있을 수 있으므로 복잡할 수 있습니다.

  • **데이터 병렬성.**작업 병렬성이란 알고리즘(명령 실행) 부분을 의미합니다. 즉, 각 스레드에서 실행되는 명령이 다릅니다. 데이터 병렬성은 동일한 명령을 나타내지만 실행되는 데이터는 다릅니다. SIMD는 데이터 병렬 처리 방법이기도 합니다.

멀티스레드 동시성의 이점은 위에 설명되어 있으며 다음에는 부작용에 대해 설명하겠습니다. 요약하면 부작용은 다음과 같습니다.

  • **데이터 경쟁을 유발합니다.**멀티 스레드 액세스는 종종 동일한 코드 조각을 교차 실행하거나 동일한 리소스를 작동하거나 멀티 코어 CPU에서 높은 캐시 동기화 문제가 있습니다. 이러한 변경으로 인해 다양한 데이터 동기화 오류나 데이터 읽기 및 쓰기 오류가 발생하여 다양한 비정상적인 결과가 발생합니다. 이것이 데이터 경쟁이다.

  • **로직이 복잡하고 디버그하기 어렵습니다.**멀티스레딩의 동시성 방식은 독특하지 않고 예측 불가능하기 때문에 데이터 경쟁을 피하기 위해 복잡하고 다양한 동기화 작업이 추가되는 경우가 많습니다. 또한 코드는 분리되고, 단편화되고, 번거롭고, 이해하기 어려워집니다. 코드 지원을 추가하면 후속 유지 관리 및 확장에 헤아릴 수 없는 장애물이 발생합니다. 또한 확률이 낮은 이벤트로 인해 재현하기 어려운 BUG가 발생하여 디버깅 및 문제 해결이 훨씬 더 어려워질 수 있습니다.

  • **반드시 효율성이 향상되는 것은 아닙니다.**멀티스레딩 기술을 사용하면 실제로 효율성이 향상되지만 절대적이지는 않습니다. 이는 물리적 코어, 동기화 메커니즘, 런타임 상태, 동시성 비율 등과 같은 요소와 관련이 있는 경우가 많습니다. 극단적인 경우 또는 적절하게 사용되지 않으면 실제로 프로그램 효율성이 저하될 수 있습니다.

18.6.1 동기화 기본 사항

18.6.1.1 병렬성과 동시성

병렬성은 두 개 이상의 스레드가 동시에 작업을 실행하는 메커니즘입니다. 일반적으로 여러 코어와 여러 물리적 스레드가 동시에 실행되는 CPU의 동작을 병렬성이라고 할 수 있지만 단일 코어의 멀티스레딩을 병렬성이라고 할 수는 없습니다.

동시성 두 개 이상의 스레드가 시간 분할을 사용하여 작업을 수행하는 메커니즘으로, 이는 보다 일반적인 병렬 처리 형태입니다. 단일 코어 CPU에서 동시에 실행되는 여러 스레드도 동시성이라고 할 수 있습니다.

imgimg

동시성의 두 가지 형태 - 상단: 듀얼 물리적 코어의 동시 실행(병렬); 하단: 단일 코어의 다중 작업 전환(동시성)

실제로 멀티코어 프로세서에서는 동시성과 병렬성이 공존할 수 있습니다. 예를 들어, 아래 그림과 같이 듀얼 코어가 있고 각 코어는 동시에 여러 작업을 전환합니다.

imgimg

일부 참고 자료에서는 병렬성과 동시성을 엄격하게 구분하지만 일부 참고 자료에서는 차이점을 명시적으로 지적하지 않습니다. 병렬성과 동시성의 개념은 Unreal Engine의 멀티스레드 렌더링 아키텍처와 API에 자주 나타나므로 Unreal은 이 둘을 명확하게 구분합니다.

18.6.1.2 암달의 법칙

클래식 동기화는 데이터 경합을 방지하는 것입니다. 데이터 경합은 두 개 이상의 스레드가 동일한 메모리 위치에 액세스하고 그 중 적어도 하나가 해당 위치에 쓸 때 발생합니다. 같은 위치에서 동시에 읽는 것은 결코 문제가 되지 않습니다. 그러나 일단 글이 등장하면 모든 베팅은 취소됩니다. 데이터가 손상될 수 있고 읽기가 손상될 수 있습니다(일부 데이터는 변경 전에 읽히고 일부 데이터는 변경 후). 여기서 동기화가 필요합니다.

일부 작업은 동시에 수행되지 않고 순차적으로 수행되어야 하기 때문에 동기화를 수행하면 성능이 저하됩니다. 실제로 문제에 더 많은 스레드/CPU를 추가하여 얻을 수 있는 속도 향상은 병렬화할 수 있는 코드의 비율에 따라 달라집니다. 암달의 법칙은 이를 잘 설명합니다.

S_{\text{대기 시간}(초) = \cfrac{1}{(1-p) + \cfrac{p}{s}

공식의 각 구성 요소의 의미는 다음과 같습니다.

  • S_{\text{latency}(s): 멀티 스레드 처리에서 전체 작업에 의해 얻어지는 이론적 속도 향상 비율입니다.

  • s: 작업의 병렬 부분을 실행하는 데 사용되는 하드웨어 리소스의 스레드 수입니다.

  • p: 병렬로 처리할 수 있는 작업의 비율입니다.

구체적인 예를 들자면, 8개의 코어와 16개의 스레드가 있는 CPU가 특정 작업을 처리하는 데 사용되고 이 작업의 70%가 병렬로 처리될 수 있다고 가정하면 이론적인 가속 비율은 다음과 같습니다.

S_{\text{대기 시간}(16) = \cfrac{1}{(1-0.7) + \cfrac{0.7}{16} = 2.9

멀티스레드 프로그래밍이 가져오는 이점은 코어 수에 정비례하지 않는다는 것을 알 수 있습니다. 실제로 그 곡선은 다음과 같습니다.

imgimg

암달의 법칙에 의해 밝혀진 코어 수 및 속도 향상 비율의 그림입니다. 병렬 작업의 비율이 낮을수록 가속 효과가 더 나쁘다는 것을 알 수 있습니다. 병렬 작업의 비율이 50%이면 기본적으로 16개의 코어가 가속 한도에 도달하며 나중에 몇 개의 코어를 추가하더라도 도움이 되지 않습니다. 병렬 작업 비율이 95%인 경우 가속 한도는 2048개 코어까지 도달하지 않습니다.

암달의 법칙은 우리에게 잔인한 현실을 가져오지만, 작업 병렬성 비율을 100%에 가깝게 늘릴 수 있다면 속도 향상 한도는 크게 향상될 수 있습니다.

S_{대기 시간}(s) = \cfrac{1}{(1-p) + \cfrac{p}{s} = \cfrac{1}{(1-1) + \cfrac{1}{s} = s

위 공식에서 볼 수 있듯이 p=1(즉, 병렬 작업 비율이 100%)일 때 이론적인 가속 비율은 코어 수에 선형적으로 비례합니다! 구체적인 예를 들자면, 언리얼 엔진 프로젝트 소스 코드나 셰이더를 컴파일할 때 기본적으로 100% 병렬이기 때문에 이론적으로 거의 선형에 가까운 가속 비율을 얻을 수 있어 멀티 코어 시스템에서 컴파일 시간을 대폭 단축할 수 있습니다.

18.6.1.3 경쟁 조건

동일한 프로세스는 다중 스레드를 허용하며 이러한 스레드는 프로세스의 주소 공간, 데이터 구조 및 컨텍스트를 공유할 수 있습니다. 프로세스의 동일한 데이터 블록에는 짧은 시간 내에 동시에 읽고 쓰는 여러 스레드가 있을 수 있으며, 이로 인해 데이터 이상이 발생하고 예측할 수 없는 결과가 발생할 수 있습니다. 이러한 예측 불가능성은 경쟁 조건을 만듭니다.

일부 운영 체제에서는 함께 작동하는 프로세스가 각 프로세스가 읽고 쓸 수 있는 일부 공통 저장소를 공유할 수 있습니다. 공유 저장소는 주 메모리(아마도 커널 데이터 구조)에 있거나 공유 파일일 수 있습니다. 공유 메모리의 위치는 통신의 성격이나 발생하는 문제를 바꾸지 않습니다. 프로세스 간 통신이 실제로 어떻게 작동하는지 이해하기 위해 이제 간단하지만 일반적인 예인 인쇄 스풀러를 고려해 보겠습니다. 프로세스가 파일을 인쇄하려고 하면 특수 스풀러 디렉터리에 파일 이름을 입력합니다. 또 다른 프로세스인 프린터 데몬은 인쇄할 파일이 있는지 주기적으로 확인하여 파일이 있으면 인쇄한 다음 디렉터리에서 해당 이름을 제거합니다.

스풀러 디렉토리에 0, 1, 2...로 번호가 매겨진 많은 수의 슬롯이 있고 각 슬롯이 파일 이름을 보유할 수 있다고 가정합니다. 인쇄할 다음 파일을 가리키는 것과 디렉토리의 다음 여유 슬롯을 가리키는 두 개의 공유 변수가 있다고 상상할 수도 있습니다. 이 두 변수는 모든 프로세스에서 사용할 수 있는 두 단어 파일에 저장될 가능성이 높습니다. 특정 순간에 슬롯 0~3은 비어 있고(파일이 인쇄됨) 슬롯 4~6은 가득 찼습니다(파일 이름이 인쇄 대기 중임). 프로세스 A와 B는 거의 동시에 인쇄를 위해 파일을 대기열에 넣기로 결정합니다. 이 상황은 아래 그림에 나와 있습니다.

머피의 법칙을 적용하면 다음과 같은 일이 발생할 수 있습니다. 프로세스 A는 값 7을 읽어 next_free_slot이라는 지역 변수에 저장합니다. 바로 그때 클럭 인터럽트가 발생하고 CPU는 프로세스 A가 충분히 오랫동안 실행되었다고 판단하여 프로세스 B로 전환합니다. 프로세스 B도 읽어서 7을 얻습니다. 이 값은 다음 여유 슬롯의 로컬 변수에도 저장됩니다. 이 시점에서 두 프로세스 모두 다음으로 사용 가능한 슬롯이 7이라고 믿습니다.

이제 프로세스 B는 계속 실행되고 파일 이름을 슬롯 7에 저장하고 이를 8로 업데이트한 다음 종료하고 다른 작업을 수행합니다.

마지막으로 프로세스 A가 다시 실행되어 중단된 부분부터 시작하여 다음 여유 슬롯을 살펴보고 거기에서 7을 찾은 다음 파일 이름을 슬롯 7에 쓰고 프로세스 B가 방금 입력한 이름을 지웁니다. 그런 다음 다음 여유 슬롯 +1(8)을 계산하여 8로 설정합니다. 스풀러 디렉토리는 이제 내부적으로 일관성이 있으므로 프린터 데몬은 오류를 포착하지 않지만 프로세스 B는 어떠한 출력도 수신하지 않습니다. 사용자 B는 출력을 결코 얻지 못하기를 열망하면서 수년 동안 프린터 주변을 맴돌 것입니다. 두 개 이상의 프로세스가 일부 공유 데이터를 읽거나 쓰고 있고 누가 언제 실행하는지에 따라 최종 결과가 달라지는 이와 같은 상황을 경쟁 조건이라고 합니다. 경쟁 조건이 포함된 프로그램을 디버깅하는 것은 전혀 재미가 없습니다. 대부분의 테스트 실행 결과는 양호하지만 때때로 이상하고 설명할 수 없는 일이 발생합니다. 불행하게도 코어 수가 증가함에 따라 병렬 처리 수준과 경쟁 조건도 점점 더 일반화되고 있습니다.

18.6.1.4 상호 배제 구현 메커니즘

일반적인 단순 상호 배제 구현 메커니즘에는 인터럽트 비활성화, 변수 잠금, 엄격한 대체 등이 포함됩니다.

  • 인터럽트 비활성화

단일 프로세서 시스템에서 가장 간단한 동기화 솔루션은 각 프로세스가 임계 영역에 들어간 직후 모든 인터럽트를 비활성화하고 떠나기 전에 다시 활성화하도록 하는 것입니다. 인터럽트가 비활성화되면 클록 인터럽트가 발생하지 않습니다. 결국 CPU는 시계나 다른 인터럽트로 인해 한 프로세스에서 다른 프로세스로만 전환할 수 있으며, 인터럽트가 꺼진 후에는 CPU가 다른 프로세스로 전환하지 않습니다. 따라서 프로세스가 인터럽트를 비활성화하면 다른 프로세스의 개입에 대해 걱정하지 않고 공유 메모리를 확인하고 업데이트할 수 있습니다.

이 접근 방식은 사용자 프로세스에 인터럽트를 끄는 기능을 제공하는 것이 현명하지 않기 때문에 일반적으로 매력적이지 않습니다. 그 중 하나가 이런 일을 했고 다시는 열리지 않았다면 어떻게 될까요? 시스템 충돌이 발생할 수 있습니다. 또한 시스템이 다중 프로세서(CPU가 2개 이상)인 경우 인터럽트를 비활성화하면 비활성화된 명령을 실행하는 CPU에만 영향을 미칩니다. 다른 것들은 계속 실행되며 공유 메모리에 액세스할 수 있습니다.

반면에 커널 자체는 변수나 특히 목록을 업데이트할 때 일부 명령에 대한 인터럽트를 편리하게 비활성화할 수 있는 경우가 많습니다. 예를 들어, 준비된 프로세스 목록이 일관되지 않은 상태에 있을 때 인터럽트가 발생하면 경쟁 조건이 발생할 수 있습니다. 요약하자면, 인터럽트를 비활성화하는 것은 운영 체제 자체 내에서 유용한 기술인 경우가 많지만 사용자 프로세스에 대한 일반적인 상호 배제 메커니즘으로는 적합하지 않습니다.

멀티 코어 칩의 수가 계속 증가함에 따라 저가형 PC에서도 코어 내 인터럽트를 비활성화하여 상호 배제를 달성하는 것이 점점 더 불가능해지고 있습니다. 듀얼 코어는 이미 널리 보급되어 있으며 많은 시스템에 4개가 있으며 8개, 16개 또는 32개도 멀지 않습니다. 멀티 코어(즉, 멀티 프로세서 시스템)에서는 하나의 CPU에 대한 인터럽트를 비활성화해도 다른 CPU가 첫 번째 CPU가 수행하는 작업을 방해하는 것을 방지할 수 없습니다. 따라서 더 복잡한 계획이 필요합니다.

  • 변수 잠금

초기 값이 0인 공유(잠금) 변수를 사용하는 것을 고려하십시오. 프로세스가 임계 영역에 들어가려고 하면 먼저 잠금을 테스트합니다. 잠금이 0이면 프로세스는 이를 1로 설정하고 임계 영역에 들어갑니다. 잠금이 이미 1이면 프로세스는 0이 될 때까지 기다립니다. 따라서 0은 임계 영역에 프로세스가 없음을 의미하고 1은 일부 프로세스가 임계 영역에 있음을 의미합니다.

불행하게도 이 아이디어에는 스풀러 디렉토리에서 본 것과 똑같은 치명적인 결함이 포함되어 있습니다. 프로세스가 잠금을 읽고 0임을 확인했다고 가정합니다. 잠금을 1로 설정하기 전에 다른 프로세스가 예약되고 실행되어 잠금을 1로 설정합니다. 첫 번째 프로세스가 다시 실행되면 잠금도 1로 설정되며 두 프로세스는 동시에 중요 영역에 있게 됩니다.

이제 잠금 값을 먼저 읽고 저장하기 전에 다시 확인하면 이 문제를 해결할 수 있다고 생각할 수도 있지만 실제로는 아무런 효과가 없습니다. 첫 번째 프로세스가 두 번째 검사를 완료한 후 두 번째 프로세스가 잠금을 수정하면 경합이 발생합니다.

  • 엄격한 회전 방식

다음 코드는 상호 배제 문제에 대한 세 번째 접근 방식을 보여줍니다. 정수 변수 rounds(초기 0)는 임계 영역에 들어가는 라운드 수를 추적하고 공유 메모리를 확인하거나 업데이트합니다. 처음에 프로세스 0은 차례를 확인하고 0임을 확인한 후 임계 영역에 들어갑니다. 프로세스 1은 또한 변수가 0임을 확인하고 따라서 긴밀한 루프에 있으며 1이 될 때 지속적으로 테스트합니다. 특정 값이 나타날 때까지 변수를 지속적으로 테스트하는 것을 바쁜 대기라고 하며 일반적으로 CPU 시간을 낭비하므로 피해야 합니다. 바쁜 대기는 대기 시간이 짧을 것이라는 합리적인 기대가 있는 경우에만 사용됩니다. 바쁜 대기를 사용하는 잠금을 스핀 잠금이라고 합니다.

cpp
// 의 。
// 0
while (TRUE) 
{ 
    while (turn != 0) /* loop */ ;
    critical_region(); 
    turn = 1; 
    noncritical_region(); 
} 

// 1
while (TRUE) 
{
    while (turn != 1) /* loop */ ;
    critical_region();
    turn = 0;
    noncritical_region();
}
  • 피터슨의 솔루션

네덜란드 수학자 T. Dekker는 차례대로 진행하는 아이디어와 잠금 변수 및 경고 변수 아이디어를 결합하여 엄격한 수정이 필요하지 않은 상호 배제 문제에 대한 소프트웨어 솔루션을 최초로 설계했습니다. 1981년에 G.L. Peterson은 상호 배제를 구현하는 더 간단한 방법을 발견하여 Dekker의 솔루션을 쓸모 없게 만들었습니다. 이 알고리즘은 다음 코드에 나와 있습니다(프로토타입 무시).

cpp
#define FALSE 0
#define TRUE  1
#define N     2 /* number of processes */

int turn; /* whose turn is it? */
int interested[N]; /* all values initially 0 (FALSE) */

void enter_region(int process); /* process is 0 or 1 */
{
    int other; /* number of the other process */
    
    other = 1 − process; /* the opposite of process */
    interested[process] = TRUE; /* show that you are interested */
    turn = process; /* set flag */
    while (turn == process && interested[other] == TRUE) /* null statement */ ;
}

void leave_region(int process) /* process: who is leaving */
{
    interested[process] = FALSE; /* indicate departure from critical region */
}
  • TSL 명령

이제 하드웨어 지원이 필요한 제안을 살펴보겠습니다. 일부 컴퓨터, 특히 다중 프로세서로 설계된 컴퓨터에는 다음과 같은 지침이 있습니다.

cpp
TSL RX, LOCK

(테스트 및 잠금 설정) 다음과 같이 작동합니다. 메모리 워드 잠금의 내용을 RX 레지스터로 읽은 다음 메모리 주소 잠금에 0이 아닌 값을 저장합니다. 단어를 읽는 작업과 단어를 저장하는 작업은 분리될 수 없으며 명령이 완료될 때까지 다른 프로세서는 메모리 단어에 액세스할 수 없습니다. TSL 명령을 실행하는 CPU는 메모리 버스를 잠궈 완료될 때까지 다른 CPU가 메모리에 액세스하는 것을 금지합니다.

메모리 버스를 잠그는 것은 인터럽트를 비활성화하는 것과 매우 다르다는 점을 기억하는 것이 중요합니다. 인터럽트를 비활성화한 다음 메모리 단어에 대한 읽기와 쓰기를 수행해도 버스의 두 번째 프로세서가 읽기와 쓰기 사이에 단어에 액세스하는 것을 막지는 못합니다. 실제로 프로세서 1의 인터럽트를 비활성화해도 프로세서 2에는 아무런 영향이 없습니다. 프로세서 1이 완료될 때까지 프로세서 2를 메모리에서 유지하는 유일한 방법은 버스를 잠그는 것입니다. 이를 위해서는 특수 하드웨어 기능(기본적으로 버스를 잠긴 프로세서 외에는 다른 프로세서가 버스를 사용할 수 없도록 잠긴 버스를 선언하는 버스)이 필요합니다.

TSL 명령어를 사용하기 위해 공유 변수 잠금을 사용하여 공유 메모리에 대한 액세스를 조정합니다. 잠금이 0이면 모든 프로세스는 TSL 명령을 사용하여 이를 1로 설정한 다음 공유 메모리를 읽거나 쓸 수 있습니다. 완료되면 프로세스는 일반 이동 명령을 사용하여 잠금을 다시 0으로 설정합니다.

두 프로세스가 동시에 중요 영역에 진입하는 것을 방지하기 위해 이 지시문을 어떻게 사용할 수 있습니까? 해결책은 가상(그러나 일반적인) 어셈블리 언어로 된 4개의 명령어 서브루틴을 보여주는 아래 그림에 나와 있습니다. 첫 번째 명령어는 이전 잠금 값을 레지스터에 복사한 다음 잠금을 1로 설정합니다. 그런 다음 이전 값을 0과 비교합니다. 0이 아니면 잠금이 설정되었음을 의미하므로 프로그램은 처음으로 돌아가서 다시 테스트합니다. 조만간 이 값은 0(현재 임계 영역에 있는 프로세스가 임계 영역을 완료할 때)이 되고 서브루틴이 반환되고 잠금이 설정됩니다. 잠금을 해제하는 것은 매우 간단합니다. 프로그램은 잠금에 0만 저장하므로 특별한 동기화 명령이 필요하지 않습니다.

이제 중요한 영역 문제에 대한 솔루션이 쉬워졌습니다. 임계 영역에 들어가기 전에 프로세스는 입력 영역을 호출하여 잠금이 해제될 때까지 바쁘게 기다립니다. 그런 다음 잠금을 획득하고 반환합니다. 임계 영역을 떠난 후 프로세스는 잠금에 0을 저장하는 이탈 영역을 호출합니다. 모든 중요한 지역 기반 솔루션과 마찬가지로 프로세스에서는 방법이 작동하려면 정확한 시간에 지역에 들어가고 나가는 것을 호출해야 합니다. 한 프로세스가 부정 행위를 하면 뮤텍스가 실패합니다. 즉, 중요한 영역은 프로세스가 협력하는 경우에만 작동할 수 있습니다.

TSL의 또 다른 명령어는 XCHG로, 레지스터와 메모리 워드 등 두 위치의 내용을 자동으로 교환합니다. 아래 코드는 기본적으로 TSL의 솔루션과 동일함을 알 수 있습니다. 모든 Intel x86 CPU는 저수준 동기화를 위해 XCHG 명령을 사용합니다.

18.6.2 스레드 동기화

18.6.2.1 원자적 연산

간단하고 빠른 것처럼 보이는 일부 작업은 실제로 스레드로부터 안전하지 않습니다. 단순한 C 변수 증분(x++)도 스레드나 다중 프로세서로부터 안전하지 않습니다. 예를 들어, 두 개의 프로세서에서 병렬로 실행되는 두 개의 스레드가 동일한 메모리 위치에서 증분을 수행한다고 가정해 보겠습니다(아래 이미지).

간단한 증분에도 읽기와 쓰기가 필요합니다. 위 다이어그램에서 각 스레드는 초기 값(0)을 CPU 레지스터로 읽을 수 있고, 각 스레드는 프로세서의 레지스터를 증가시킨 다음 결과를 다시 씁니다. X를 쓴 최종 결과는 2가 아닌 1입니다. 이 다이어그램은 CPU 캐시와 같은 다른 요소가 작용하기 때문에 크게 단순화한 것입니다. 하지만 이를 무시하더라도 이는 분명히 데이터 전쟁입니다. 실제로 스레드 중 하나(예: T2)가 선점될 수 있으며(예: R이 증가한 후) T1이 계속해서 X를 증가시키는 동안 T2가 CPU 시간을 받으면 다시 1을 씁니다. Windows에서 일반적으로 사용되는 원자 연산 API는 다음과 같습니다.

cpp
LONG     InterlockedIncrement([in, out] LONG volatile *Addend);
SHORT     InterlockedIncrement16([in, out] SHORT volatile *Addend);
LONG64     InterlockedIncrement64([in, out] LONG64 volatile *Addend);
LONG    InterlockedIncrementNoFence(_Inout_ LONG volatile *Addend);

LONG     InterlockedDecrement([in, out] LONG volatile *Addend);
LONG     InterlockedDecrement16([in, out] LONG volatile *Addend);
LONG     InterlockedDecrement64([in, out] LONG volatile *Addend);

LONG     InterlockedOr([in, out] LONG volatile *Destination, [in] LONG Value);
LONG     InterlockedExchange([in, out] LONG volatile *Target, [in] LONG Value);

// 의 32변수의 로써 원자적 (1), 을(를) 활용하여 페치/가져오기(Fetch)메모리 실행(Execute)。
LONG     InterlockedIncrementAcquire(_Inout_ volatile *Addend);
LONG      InterlockedIncrementRelease(_Inout_ LONG volatile *Addend);

(...)

18.6.2.2 중요 섹션

정수 증가와 같은 간단한 경우에는 연동된 함수 계열이 잘 작동합니다. 그러나 다른 작업의 경우에는 보다 일반적인 메커니즘이 필요합니다. 중요 섹션은 잠금을 획득하는 최대 하나의 스레드를 기반으로 하는 고전적인 동기화 메커니즘입니다. 좋은 해결책을 찾으려면 네 가지 조건이 필요합니다.

  1. 두 프로세스가 임계 영역에 동시에 존재하는 것은 불가능합니다.

  2. 속도나 CPU 수에 대해서는 어떠한 가정도 할 수 없습니다.

  3. 중요 영역 외부에서 실행되는 프로세스는 프로세스를 차단하지 않습니다.

  4. 어떤 프로세스도 임계 영역에 들어가기 위해 영원히 기다려서는 안 됩니다.

추상적으로 우리가 원하는 동작은 아래와 같습니다. 프로세스 A는 T1 시간에 임계 섹션에 들어가고 그 이후 T2 시간에 프로세스 B가 임계 섹션에 들어가려고 시도하지만 다른 프로세스가 이미 임계 섹션에 있고 한 번에 하나의 프로세스만 허용하기 때문에 실패합니다. 따라서 A가 임계 영역을 벗어나면 B는 T3 시간까지 일시적으로 일시 중지되어 B가 즉시 들어갈 수 있도록 합니다. 결국 B는 (T4에서) 떠나고 임계 영역에는 프로세스가 없는 원래 상황으로 돌아갑니다.

스레드가 특정 잠금을 획득하면 이를 처음 획득한 스레드가 잠금을 해제할 때까지 다른 스레드는 동일한 잠금을 획득할 수 없습니다. 그런 다음에만 대기 중인 스레드 중 하나만 잠금을 획득할 수 있습니다. 즉, 특정 순간에 단 하나의 스레드만 잠금을 획득합니다. (아래 사진)

잠금을 획득한 스레드는 소유자이기도 하며 이는 다음 두 가지를 의미합니다.

  1. 소유자 스레드는 중요한 부분을 해제할 수 있는 유일한 스레드입니다.

  2. 소유자 스레드가 임계 섹션을 두 번째로(재귀적으로) 획득하려고 시도하면 자동으로 성공하고 내부 카운터가 증가합니다. 즉, 소유자 스레드는 이제 임계 섹션을 실제로 해제하기 전에 동일한 횟수를 해제해야 함을 의미합니다.

잠금 획득과 잠금 해제 사이의 코드를 임계 영역이라고 합니다.

중요 섹션과 관련된 일반적인 Windows API는 다음과 같습니다.

cpp
// 초기화(Initialize)
void InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
BOOL InitializeCriticalSectionAndSpinCount(LPCRITICAL_SECTION lpCriticalSection, DWORD dwSpinCount);
BOOL InitializeCriticalSectionEx(LPCRITICAL_SECTION lpCriticalSection, DWORD dwSpinCount, DWORD Flags);

//
void DeleteCriticalSection(LPCRITICAL_SECTION lpCriticalSection);

//
void EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
//
void LeaveCriticalSection(LPCRITICAL_SECTION lpCriticalSection);

중요한 섹션에 들어가고 나가는 것은 쌍으로 이루어져야 하므로 RAII 메커니즘을 사용하여 이를 보장할 수 있습니다. 샘플 코드는 다음과 같습니다.

cpp
struct AutoCriticalSection 
{
    AutoCriticalSection(CRITICAL_SECTION& cs)
    : _cs(cs) 
    {
        ::EnterCriticalSection(&_cs);
    }
    ~AutoCriticalSection()
    {
        ::LeaveCriticalSection(&_cs);
    }
    
    // delete copy ctor, move ctor, assignment operators
    AutoCriticalSection(const AutoCriticalSection&) = delete;
    AutoCriticalSection& operator=(const AutoCriticalSection&) = delete;
    AutoCriticalSection(AutoCriticalSection&&) = delete;
    AutoCriticalSection& operator=(AutoCriticalSection&&) = delete;
    
private:
    CRITICAL_SECTION& _cs;
};

물론, C++ 객체로 임계 섹션을 캡슐화하여 보다 친숙하고 안전한 액세스 인터페이스를 제공할 수도 있습니다.

cpp
class CriticalSection : public CRITICAL_SECTION 
{
public:
    CriticalSection(DWORD spinCount = 0, DWORD flags = 0)
    {
        ::InitializeCriticalSectionEx(this, (DWORD)spinCount, flags);
    }
    ~CriticalSection()
    {
        ::DeleteCriticalSection(this);
    }
    
    void Lock()
    {
        ::EnterCriticalSection(this);
    }
    void Unlock()
    {
        ::LeaveCriticalSection(this);
    }
    bool TryLock()
    {
        return ::TryEnterCriticalSection(this);
    }
};

임계 구역의 문제: n개의 프로세스(P0, P1,..., Pn-1)로 구성된 시스템을 고려하십시오. 각 프로세스에는 코드 조각이 있습니다. 이 코드를 임계 섹션이라고 하며 프로세스가 공용 변수를 변경하고, 테이블을 업데이트하고, 파일을 쓰는 등의 작업을 수행할 수 있습니다. 시스템의 중요한 특징은 프로세스가 임계 섹션에서 실행 중일 때 다른 프로세스가 중요한 부분에서 실행될 수 없으며 프로세스별 임계 섹션의 실행이 상호 배타적이라는 것입니다.

임계 영역 문제는 프로세스가 협력하는 데 사용할 수 있는 프로토콜을 설계하는 것이며, 각 프로세스는 임계 영역에 들어가기 위해 권한을 요청해야 합니다. 이 요청을 구현하는 코드 부분은 진입 영역이고, 종료 영역은 임계 섹션 다음에 오고, 나머지 코드는 나머지 영역입니다.

임계 구역 문제에 대한 해결책은 다음 세 가지 조건을 만족해야 합니다.

  • 상호 배제: Pi 프로세스가 임계 섹션에서 실행되면 다른 프로세스는 임계 섹션에서 실행될 수 없습니다. 상호 배타적인 요구 사항:

상호 배제를 적용해야 합니다. 동일한 리소스 또는 공유 개체에 대한 임계 섹션이 있는 모든 프로세스 중에서 한 번에 하나의 프로세스만 임계 섹션에 들어갈 수 있습니다.

  • 중요하지 않은 구간에서 정지된 프로세스는 다른 프로세스에 지장을 주지 않고 정지되어야 한다.

  • 중요한 섹션에 대한 액세스가 필요한 프로세스는 무기한 지연될 수 없습니다. 교착 상태나 기아 상태가 발생할 수 없습니다.

  • 크리티컬 섹션에 프로세스가 없을 때, 크리티컬 섹션 진입을 요청하는 프로세스는 즉시 진입을 허용해야 합니다.

  • 상대적인 프로세스 속도나 프로세서 수에 대한 가정은 이루어지지 않습니다.

  • 프로세스는 제한된 시간 동안만 임계 영역에 머뭅니다.

  • 프로세스: 임계 섹션에서 실행 중인 프로세스가 없고 일부 프로세스가 임계 섹션에 들어가려는 경우 나머지 섹션에서 실행되지 않는 프로세스만 임계 섹션에 들어갈 수 있습니다.

  • 대기 제한: 프로세스가 요청을 발행한 후 다른 프로세스가 임계 섹션에 들어갈 수 있는 횟수에는 제한이 있습니다.

하드웨어 상호 배제 방법은 다음과 같습니다.

  • 인터럽트 비활성화됨

단일 프로세서 시스템에서는 동시 프로세스가 겹칠 수 없으며 인터리브만 가능합니다. 또한 프로세스는 운영 체제 서비스를 호출하거나 중단될 때까지 계속 실행됩니다. 따라서 상호 배제를 보장하려면 프로세스가 중단되는 것을 방지하는 것으로 충분합니다. 이 기능은 인터럽트를 비활성화하고 활성화하기 위해 시스템 커널 정의 기본 형식으로 제공됩니다. 중요 섹션 문제를 해결하려면 잠금을 사용하세요.

cpp
do
{
    acquire lock
        critical section;
    release lock
        remainder section;
} while (TRUE);

중요한 섹션은 중단될 수 없으므로 상호 배제가 보장됩니다.

단점은 단일 프로세서 환경에서만 작동하며 제때에 유지되지 않으면 인터럽트가 손실되고 임계 섹션에 들어가기 위해 대기 중인 프로세스가 중단될 수 있다는 것입니다.

  • 테스트 및 설정 지침

상호 배제를 피하기 위해 사용되는 특수한 기계 명령입니다. 테스트 및 설정 지침은 다음과 같이 정의할 수 있습니다.

cpp
boolean TestAndSet (boolean *target)
{
    boolean rv = *target;
    *target = TRUE;
    return rv:
}

위의 기능은 자동으로 수행됩니다. TestAndSet을 사용하는 솔루션에서는 공유 부울 변수 잠금이 false로 초기화됩니다.

cpp
do 
{
    while ( TestAndSet (&lock ))
        ; // do nothing
     // critical section
    lock = FALSE;
    // remainder section
} while (TRUE);

장점: 간단하고 확인하기 쉬우며 여러 프로세스에 적용 가능하며 여러 중요 섹션을 지원하는 데 사용할 수 있습니다. 단점: 바쁜 대기(Busy Waiting), 기아(Starvation), 교착상태(Deadlock)가 발생할 수 있습니다.

  • 교환 명령
cpp
void Swap (boolean *a, boolean *b)
{
    boolean temp = *a;
    *a = *b;
    *b = temp:
}

공유 부울 변수 잠금은 FALSE로 초기화되고 각 프로세스에는 로컬 부울 변수 키가 있습니다.

cpp
do 
{
    key = TRUE;
    while( key == TRUE)
        Swap(&lock, &key);
    // critical section
    lock = FALSE;
    // remainder section
} while (TRUE);

TestandSet()을 사용한 제한된 대기 뮤텍스:

cpp
do 
{
    waiting[i] = TRUE;
    key = TRUE;
    while (waiting[i] && key)
        key = TestAndSet(&lock);
    waiting[i] = FALSE;
        // critical section
    j = (i + 1) % n;
    while ((j != i) && !waiting[j])
        j = (j + 1) % n;
    if (j == i)
        lock = FALSE;
    else
        waiting[j] = FALSE;
    // remainder section
} while (TRUE);

상호 배제 문제에 대한 Peterson의 해결책은 두 개 이상의 스레드가 동시에 임계 영역에 있는 것을 방지하기 위해 사전 프로토콜(또는 항목 프로토콜)과 사후 프로토콜(또는 기존 프로토콜)을 설계하는 것입니다. Tanenbaum은 임계 섹션 문제 또는 상호 배제 문제에 대한 제안을 연구했습니다. 문제는 한 프로세스가 임계 섹션에서 수정 가능한 공유 데이터를 업데이트할 때 다른 프로세스가 임계 섹션에 들어가도록 허용해서는 안 된다는 것입니다. 중요 섹션에 대한 권장 사항은 다음과 같습니다.

  • 인터럽트를 비활성화합니다(하드웨어 솔루션).

각 프로세스는 임계 섹션에 들어간 후 모든 인터럽트를 비활성화하고 임계 섹션을 떠나기 전에 모든 인터럽트를 다시 활성화합니다. 인터럽트가 꺼지면 CPU는 다른 프로세스로 전환할 수 없습니다. 따라서 다른 프로세스는 임계 섹션의 상호 배타적 상태에 들어갈 수 없습니다.

인터럽트를 비활성화하는 것은 때때로 유용한 인터럽트이고 때로는 운영 체제 커널에서 유용한 기술이지만 사용자 프로세스에 대한 일반적인 상호 배제 메커니즘으로는 적합하지 않습니다. 그 이유는 사용자 프로세스에 인터럽트를 끌 수 있는 권한을 부여하는 것이 현명하지 않기 때문입니다.

  • 변수 잠금(소프트웨어 솔루션).

이 솔루션에서는 초기 값이 0인 단일 공유(잠금) 변수를 고려합니다. 프로세스가 임계 섹션에 들어가려고 하면 먼저 잠금을 테스트합니다. lock이 0이면 프로세스는 먼저 이를 1로 설정한 다음 임계 구역으로 들어갑니다. 잠금이 이미 1인 경우 프로세스는 (잠금) 변수가 0이 될 때까지 기다립니다. 따라서 0은 임계 섹션에 프로세스가 없음을 의미하고 1은 일부 프로세스가 임계 섹션에 있음을 의미합니다.

이 제안의 결함은 예를 들어 설명할 수 있습니다. 프로세스 A가 잠금을 0으로 본다고 가정합니다. 잠금을 1로 설정하기 전에 다른 프로세스 B가 예약되고 실행되어 잠금을 1로 설정합니다. 프로세스 A가 다시 실행되면 잠금도 1로 설정되고 두 프로세스는 동시에 중요 섹션에 있게 됩니다.

  • 엄격한 대안.

제안된 이 솔루션에서 정수 변수 "turn"은 누가 임계 구역에 들어갈 것인지 추적합니다. 처음에 프로세스 A는 라운드를 확인하고 0임을 확인하고 임계 섹션에 들어갑니다. 프로세스 B는 또한 자신이 0이고 루프에 있음을 발견하고 "turn"을 지속적으로 테스트하여 언제 1로 변경되는지 확인하고 특정 값이 나타나기를 기다리는 변수를 지속적으로 테스트하는 것을 Busy-Waiting이라고 합니다.

프로세스 중 하나가 다른 프로세스보다 훨씬 느린 경우 교대로 수행하는 것은 좋은 생각이 아닙니다. 프로세스 0이 임계 섹션을 빠르게 완료하여 이제 두 프로세스가 모두 중요하지 않은 섹션에 있다고 가정합니다. 이는 위의 조건 3(제한된 대기)을 위반하는 상황입니다.

18.6.2.3 읽기-쓰기 잠금

중요한 섹션을 사용하여 동시 액세스로부터 공유 데이터를 보호하는 것은 잘 작동하지만 이는 비관적인 메커니즘입니다. 최대 하나의 스레드가 공유 데이터에 액세스할 수 있습니다. 경우에 따라 일부 스레드는 데이터를 읽는 동안 다른 스레드는 데이터를 작성하며 이는 최적화될 수 있습니다. 한 스레드가 데이터를 읽는 경우 데이터만 읽는 다른 스레드가 동시에 실행되는 것을 막을 이유가 없습니다. 즉, "단일 쓰기 다중 읽기" 메커니즘입니다. Windows API는 이러한 종류의 잠금을 나타내는 SRWLOCK 구조를 제공합니다(S는 "Slim"을 나타냄). 해당 정의 및 관련 API는 다음과 같습니다.

cpp
typedef struct _RTL_SRWLOCK 
{
    PVOID Ptr;
} RTL_SRWLOCK, *PRTL_SRWLOCK;

typedef RTL_SRWLOCK SRWLOCK, *PSRWLOCK;

void InitializeSRWLock(_Out_ PSRWLOCK SRWLock);
void AcquireSRWLockShared(_InOut_ PSRWLOCK SRWLock);
void AcquireSRWLockExclusive(_InOut_ PSRWLOCK SRWLock);
void ReleaseSRWLockShared(_Inout_ PSRWLOCK SRWLock);
void ReleaseSRWLockExclusive(_Inout_ PSRWLOCK SRWLock);

읽기-쓰기 잠금은 RAII를 사용하여 캡슐화할 수도 있습니다.

cpp
class ReaderWriterLock : public SRWLOCK 
{
public:
    ReaderWriterLock();
    ReaderWriterLock(const ReaderWriterLock&) = delete;
    ReaderWriterLock& operator=(const ReaderWriterLock&) = delete;
    void LockShared();
    void UnlockShared();
    void LockExclusive();
    void UnlockExclusive();
};

struct AutoReaderWriterLockExclusive 
{
    AutoReaderWriterLockExclusive(SRWLOCK& lock);
    ~AutoReaderWriterLockExclusive();
private:
    SRWLOCK& _lock;
};

struct AutoReaderWriterLockShared 
{
    AutoReaderWriterLockShared(SRWLOCK& lock);
    ~AutoReaderWriterLockShared();
private:
    SRWLOCK& _lock;
};

// 인터페이스 의 구현
ReaderWriterLock::ReaderWriterLock() 
{
    ::InitializeSRWLock(this);
}
void ReaderWriterLock::LockShared() 
{
    ::AcquireSRWLockShared(this);
}
void ReaderWriterLock::UnlockShared() 
{
    ::ReleaseSRWLockShared(this);
}
void ReaderWriterLock::LockExclusive() 
{
    ::AcquireSRWLockExclusive(this);
}
void ReaderWriterLock::UnlockExclusive() 
{
    ::ReleaseSRWLockExclusive(this);
}
AutoReaderWriterLockExclusive::AutoReaderWriterLockExclusive(SRWLOCK& lock)
: _lock(lock) 
{
    ::AcquireSRWLockExclusive(&_lock);
}
AutoReaderWriterLockExclusive::~AutoReaderWriterLockExclusive() 
{
    ::ReleaseSRWLockExclusive(&_lock);
}
AutoReaderWriterLockShared::AutoReaderWriterLockShared(SRWLOCK& lock)
: _lock(lock) 
{
    ::AcquireSRWLockShared(&_lock);
}
AutoReaderWriterLockShared::~AutoReaderWriterLockShared() 
{
    ::ReleaseSRWLockShared(&_lock);
}

18.6.2.4 조건 변수

조건 변수는 특정 조건이 발생할 때까지 중요 섹션 또는 SRW 잠금을 기다리는 기능을 제공하는 또 다른 동기화 메커니즘입니다. 조건 변수의 전형적인 적용 사례는 생산자/소비자 시나리오입니다. 일부 스레드가 데이터 항목을 생성하여 대기열에 배치한다고 가정합니다. 각 스레드는 항목을 생성하는 데 필요한 모든 작업을 수행합니다. 동시에 다른 스레드는 소비자 역할을 합니다. 각 스레드는 대기열에서 항목을 제거하고 어떤 방식으로든 처리합니다(아래 이미지).

소비자가 처리할 수 있는 것보다 항목이 더 빨리 생산되는 경우 대기열은 비어 있지 않으며 소비자는 계속 작업합니다. 반면에 소비자 스레드가 모든 항목을 처리하는 경우 새 항목이 생성될 때까지 대기 상태에 들어가야 하며, 이 경우 깨어나야 합니다. 이는 정확히 조건 변수에서 제공하는 동작입니다. 관련되지 않은 소비자 스레드(빈 큐 포함)는 회전하지 않아야 하며 큐가 비어 있지 않은지 주기적으로 확인해야 합니다. 이렇게 하면 아무 이유 없이 CPU 사이클을 소비하게 되기 때문입니다. 조건 변수를 사용하면 스레드가 깨어날 때까지(일반적으로 생산자 스레드에 의해) 효율적인 대기(CPU 소비 없음)가 가능합니다.

Windows의 조건 변수는 SRWLOCK과 유사한 CONDITION_VARIABLE 불투명 구조로 표현됩니다. 관련 API는 다음과 같습니다.

cpp
void InitializeConditionVariable(PCONDITION_VARIABLE ConditionVariable);
BOOL SleepConditionVariableCS(PCONDITION_VARIABLE ConditionVariable, PCRITICAL_SECTION CriticalSection, DWORD dwMilliseconds);
BOOL SleepConditionVariableSRW(PCONDITION_VARIABLE ConditionVariable, PSRWLOCK SRWLock, DWORD dwMilliseconds, ULONG Flags);
VOID WakeConditionVariable (PCONDITION_VARIABLE ConditionVariable);
VOID WakeAllConditionVariable (PCONDITION_VARIABLE ConditionVariable);

스레드가 활성화되면 동기화 개체를 다시 획득하고 실행을 계속합니다. 이 시점에서 스레드는 기다리고 있는 조건을 다시 확인해야 하며, 충족되지 않으면 Sleep* 함수를 다시 호출해야 합니다. 아래와 같이(중요 섹션 사용)

위 그림과 관련된 단계는 다음과 같습니다.

  1. 사용자 스레드가 임계 섹션을 획득합니다.

  2. 스레드는 계속할 수 있는지 확인합니다. 예를 들어 처리해야 할 큐가 비어 있는지 확인할 수 있습니다.

  3. 비어 있는 경우 스레드는 중요한 섹션을 해제하고(다른 스레드가 이를 얻을 수 있도록) 절전(대기 상태)에 들어가는 SleepConditionVariableCS를 호출합니다.

  4. 새 항목이 대기열에 추가되는 경우와 같이 어느 시점에서 생산자 스레드는 WakeConditionVariable을 호출하여 소비자 스레드를 깨울 것입니다.

  5. SleepConditionVariableCS가 반환되어 임계 섹션을 얻은 다음 계속할 수 있는지 확인하기 위해 반환됩니다. 그렇지 않은 경우 계속 대기하게 됩니다.

  6. 이제 계속할 수 있으며 스레드는 작업(예: 대기열에서 항목 제거)을 수행할 수 있습니다. 중요한 부분이 남아 있습니다.

  7. 마지막으로 작업이 완료되고 비판적 구별이 해제되어야 합니다.

18.6.2.5 대기 주소

Windows 8 및 Server 2012에는 스레드가 특정 주소 값이 원하는 값으로 변경될 때까지 효율적으로 대기한 다음 깨어나 작업을 계속할 수 있도록 하는 또 다른 동기화 메커니즘이 추가되었습니다. 물론 조건 변수를 사용하는 것과 같은 다른 동기화 메커니즘을 사용하여 비슷한 효과를 얻을 수 있지만 중요한 섹션(또는 다른 소프트웨어 동기화 프리미티브)이 직접 사용되지 않기 때문에 주소 대기가 더 효율적이고 교착 상태가 발생할 가능성이 적습니다. 스레드는 "모니터링되는" 데이터 관련 API에 값이 나타날 때까지 WaitOnAddress를 호출하여 대기 상태로 들어갈 수 있습니다.

cpp
BOOL WaitOnAddress(volatile VOID* Address, PVOID CompareAddress, SIZE_T AddressSize, DWORD dwMilliseconds);
VOID WakeByAddressSingle(_In_ PVOID Address);
VOID WakeByAddressAll(_In_ PVOID Address);

18.6.2.6 동기화 장벽

Windows 8에 도입된 또 다른 동기화 기본 요소는 동기화 장벽으로, 이를 통해 작업을 계속하기 전에 작업의 특정 지점에 도달해야 하는 스레드의 동기화를 허용합니다. 예를 들어, 시스템에 여러 부분이 있고 각 부분은 기본 애플리케이션 코드를 계속하기 전에 두 단계로 초기화해야 한다고 가정합니다. 이를 달성하는 간단한 방법은 각 초기화 함수를 순차적으로 호출하는 것입니다.

cpp
void RunApp()
{
    // phase 1
    InitSubsystem1();
    InitSubsystem2();
    InitSubsystem3();
    InitSubsystem4();
    
    // phase 2
    InitSubsystem1Phase2();
    InitSubsystem2Phase2();
    InitSubsystem3Phase2();
    InitSubsystem4Phase2();
    
    // go ahead and run main application code...
}

위의 작업이 작동하는 동안 각 초기화가 동시에 발생할 수 있는 경우 각 초기화는 다른 스레드에 의해 수행됩니다. 각 스레드는 다른 모든 스레드가 1단계를 완료할 때까지 2단계 초기화를 진행해서는 안 됩니다. 물론 이 체계는 다른 동기화 기본 요소의 조합을 사용하여 구현할 수 있지만 이 목적을 위한 동기화 장벽이 이미 존재합니다. Windows는 이를 표현하기 위해 SYNCHRONIZATION_BARRIER 불투명 구조를 사용하고, 초기화를 위해 InitializeSynchronizationBarrier를 사용합니다. 관련 API:

cpp
BOOL InitializeSynchronizationBarrier(LPSYNCHRONIZATION_BARRIER lpBarrier, LONG lTotalThreads, LONG lSpinCount);
BOOL EnterSynchronizationBarrier(LPSYNCHRONIZATION_BARRIER lpBarrier, DWORD dwFlags);

이 함수는 장벽을 해제한 후 단일 스레드에 대해서는 TRUE만 반환하고 다른 모든 스레드에 대해서는 FALSE를 반환합니다. 앞에서 설명한 시나리오에서 다음은 별도의 스레드에서 실행되는 초기화 함수 중 하나입니다.

cpp
DWORD WINAPI InitSubSystem1(PVOID p) 
{
    auto barrier = (PSYNCHRONIZATION_BARRIER)p;
    
    // phase 1
    printf("Subsystem 1: Starting phase 1 initialization (TID: %u)...\n", ::GetCurrentThreadId());
    // do work...
    printf("Subsystem 1: Ended phase 1 initialization...\n");
    
    // 배리어
    ::EnterSynchronizationBarrier(barrier, 0);
    
    printf("Subsystem 1: Starting phase 2 initialization...\n");
    // do work
    printf("Subsystem 1: Ended phase 2 initialization...\n");
    
    return 0;
}

1단계 초기화가 완료된 후 EnterSynchronizationBarrier를 호출하고 다른 모든 스레드가 1단계 초기화를 완료할 때까지 기다립니다. 주요 함수는 다음과 같이 작성할 수 있습니다.

cpp
SYNCHRONIZATION_BARRIER sb;
// 초기화(Initialize)배리어
InitializeSynchronizationBarrier(&sb, 4, -1);
LPTHREAD_START_ROUTINE functions[] = {InitSubSystem1, InitSubSystem2, InitSubSystem3, InitSubSystem4};
printf("System initialization started\n");

HANDLE hThread[4];
int i = 0;
for (auto f : functions) 
{
    hThread[i++] = ::CreateThread(nullptr, 0, f, &sb, 0, nullptr);
}
// 배리어
::WaitForMultipleObjects(_countof(hThread), hThread, TRUE, INFINITE);

printf("System initialization complete\n");
// close thread handles...

18.6.3 프로세스 통신 및 동기화

**IPC(프로세스 간 통신)**는 프로세스가 서로 통신하고 작업을 동기화할 수 있도록 하는 메커니즘입니다. 이러한 프로세스 간의 커뮤니케이션은 프로세스 간의 협력 방식으로 간주될 수 있습니다. 운영 체제에서 동시에 실행되는 프로세스는 독립적인 프로세스일 수도 있고 협업 프로세스일 수도 있습니다. 프로세스는 시스템에서 실행되는 다른 프로세스에 영향을 줄 수 없거나 영향을 받지 않으면 독립적입니다. 다른 프로세스와 데이터를 공유하지 않는 프로세스는 독립적입니다. 프로세스는 시스템에서 실행되는 다른 프로세스에 영향을 주거나 영향을 받을 수 있는 경우 협력적입니다. 분명히 다른 프로세스와 데이터를 공유하는 모든 프로세스는 협력 프로세스입니다. 프로세스 협력 이유:

  • 정보 공유. 여러 사용자가 동일한 정보(예: 파일 공유)에 관심을 가질 수 있으므로 해당 정보에 동시에 액세스할 수 있는 환경을 제공해야 합니다.

  • 계산 가속. 특정 작업을 더 빠르게 실행하려면 하위 작업으로 나누어야 하며, 각 하위 작업은 다른 작업과 병렬로 실행됩니다. 이 속도 향상은 컴퓨터에 여러 개의 처리 코어가 있는 경우에만 가능합니다.

  • 모듈식. 우리는 시스템 기능을 별도의 프로세스나 스레드로 나누어 모듈식 방식으로 시스템을 구축하고 싶을 수도 있습니다.

  • 편의. 한 명의 사용자라도 여러 작업을 동시에 처리할 수 있습니다. 예를 들어, 사용자는 동시에 편집하고, 음악을 듣고, 컴파일할 수 있습니다.

프로세스 간 통신 메커니즘.

프로세스 간 통신에는 두 가지 기본 모델이 있습니다.

  • 공유 메모리. 공유 메모리 모델에서는 협력 프로세스가 공유하는 메모리 영역이 설정됩니다. 그런 다음 프로세스는 공유 영역에 데이터를 읽고 쓰는 방식으로 정보를 교환할 수 있습니다. 공유 메모리는 메시지 전달 시스템이 일반적으로 시스템 호출을 사용하여 구현되고 따라서 커널 개입에 더 많은 시간이 소요되는 작업이 필요하기 때문에 메시지 전달보다 더 빠를 수 있습니다.

  • 메시징. 메시지 전달 모델에서 통신은 협력 프로세스 간에 교환되는 메시지를 통해 발생합니다. 메시지 전달은 충돌을 피할 필요가 없고 공유 메모리보다 분산 시스템에서 구현하기 쉽기 때문에 소량의 데이터를 교환하는 데 유용합니다.

메시지 예시.

Solaris 동기화 데이터 구조.

Windows 동기화 개체.

간접적인 프로세스 통신.

공유 메모리 시스템에서 공유 메모리를 사용하는 프로세스 간 통신을 위해서는 통신 프로세스에서 공유 메모리 영역을 설정해야 합니다. 일반적으로 공유 메모리 영역은 공유 메모리 세그먼트를 생성한 프로세스의 주소 공간에 있습니다. 이 공유 메모리 세그먼트를 사용하여 통신하려는 다른 프로세스는 이를 주소 공간에 연결해야 하며 종종 운영 체제는 한 프로세스가 다른 프로세스의 메모리에 액세스하는 것을 방지하려고 시도합니다. 공유 메모리에서는 두 개 이상의 프로세스가 이 제한을 제거하는 데 동의해야 합니다. 그런 다음 공유 영역에서 데이터를 읽고 쓰면서 정보를 교환할 수 있습니다. 데이터의 형식과 위치는 이러한 프로세스에 의해 결정되며 운영 체제에 의해 제어되지 않습니다. 이러한 프로세스는 동시에 동일한 위치에 쓰지 않도록 하는 역할도 담당합니다.

경합 조건은 여러 프로세스가 동시에 동일한 데이터에 액세스하고 작동하는 상황이며, 실행 결과는 액세스가 발생하는 특정 순서에 따라 달라집니다. 두 프로세스 P1과 P2가 전역 변수 a를 공유한다고 가정합니다. 실행 중에 P1은 a를 값 1로 업데이트하고, 실행 중 어느 시점에서 P2는 a를 값 2로 업데이트합니다. 따라서 이 두 작업은 변수 a에 쓰기 위해 경쟁합니다. 이 경우 경기의 "패자"(마지막 업데이트 프로세스)가 a의 최종 값을 결정합니다.

따라서 운영 체제는 다음 사항에 중점을 둡니다.

  • 운영체제는 다양한 프로세스를 추적할 수 있어야 합니다.

  • 운영체제는 각 활성 프로세스에 대해 다양한 자원을 할당하고 할당 해제해야 합니다.

  • 운영체제는 다른 프로세스의 의도하지 않은 간섭으로부터 각 프로세스의 데이터와 물리적 자원을 보호해야 합니다.

  • 다른 동시 프로세스에 상대적인 속도, 기능 및 출력은 실행 속도와 독립적이어야 합니다.

프로세스 상호작용은 서로에 대한 비인식, 간접적인 인식, 직접적인 인식으로 정의할 수 있습니다. 동시 프로세스는 다음 상황 중 하나에서 충돌할 수 있습니다.

  • 동일한 자원을 사용하기 위해 경쟁합니다.

  • 둘 이상의 프로세스가 실행 중에 리소스에 액세스해야 합니다.

  • 각 프로세스는 다른 프로세스의 존재를 인식하지 못합니다.

  • 경쟁 프로세스 간에는 정보가 교환되지 않습니다.

프로세스는 인터럽트를 사용하는 것보다 잘 구조화된 방식으로 다른 프로세스와 통신해야 하는 경우가 많습니다. 간단히 말해서 세 가지 질문이 있습니다.

  1. 한 프로세스가 다른 프로세스로 정보를 전송하는 방법.

  2. 두 개 이상의 프로세스가 서로를 방해하지 않도록 하는 것과 관련됩니다. 예를 들어 항공사 예약 시스템의 두 프로세스는 각각 다른 고객을 위해 비행기의 마지막 좌석을 확보하려고 합니다.

  3. 종속성이 있는 경우 올바른 순서: 프로세스 A가 데이터를 생성하고 프로세스 B가 이를 인쇄하는 경우 B는 인쇄를 시작하기 전에 A가 일부 데이터를 생성할 때까지 기다려야 합니다.

이 문제 중 두 가지가 스레드에도 동일하게 적용된다는 점에 유의하는 것도 중요합니다. .

18.6.3.1 스케줄링 개체

Windows 커널 개체와 관련된 가장 중요한 사항은 다음과 같습니다.

  • 커널 개체는 시스템(커널) 공간에 위치하며 이론적으로 프로세스가 요청된 개체에 대한 핸들을 얻을 수 있다면 모든 프로세스에서 액세스할 수 있습니다.

  • 핸들은 프로세스와 관련되어 있습니다.

  • 프로세스 간에 개체를 공유하는 방법에는 상속 처리, 이름 지정 및 복사 처리의 세 가지 방법이 있습니다.

일부 커널 개체는 디스패처 개체 또는 대기 가능 개체라고 하는 좀 더 특별합니다. 이러한 객체는 신호 받음 또는 신호 받지 않음의 두 가지 상태 중 하나일 수 있습니다. 신호를 받음과 신호를 받지 않음의 의미는 객체 유형에 따라 다릅니다. 다음 표에는 일반적인 일정 개체에 대한 이러한 상태의 의미가 요약되어 있습니다.

객체 유형 신호가 있습니다 신호 없음

프로세스 종료/해지됨 달리기

스레드 종료/해지됨 달리기

직업 작업 종료 시간에 도달했습니다. 한도에 도달하지 않았거나 설정되지 않았습니다.

뮤텍스 무료(소유자 없음) 소유되다

세마포어 0보다 큰 수 개수는 0입니다.

이벤트 이벤트가 설정되었습니다 이벤트가 설정되지 않았습니다

파일 I/O 작업이 완료되었습니다. I/O 작업이 진행 중이거나 시작되지 않았습니다.

대기 가능한 타이머 타이머 카운트가 만료되었습니다 타이머 카운트가 만료되지 않았습니다.

I/O 완료 비동기 I/O 작업이 완료되었습니다. 비동기 I/O 작업이 완료되지 않았습니다.

객체가 신호를 받을 때까지 기다리는 것은 일반적으로 다음 두 가지 기능 중 하나에 의해 수행됩니다(자체 대기 기능이 있는 I/O 완료 포트 제외).

cpp
DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);
DWORD WaitForMultipleObjects(DWORD nCount, CONST HANDLE* lpHandles, BOOL bWaitAll, DWORD dwMilliseconds);

WaitForSingleObject에는 네 가지 반환 값이 있을 수 있습니다.

  • WAIT_OBJECT_0: 제한 시간이 만료되기 전에 개체에 신호가 전달되어 대기가 종료되었습니다.

  • WAIT_TIMEOUT: 스레드가 대기하는 동안 개체가 신호를 내보내지 않았습니다. 제한 시간이 무한대인 경우 이 값은 반환되지 않습니다.

  • WAIT_FAILED: 어떤 이유로 함수가 실패했습니다.

  • WAIT_ABANDONED: 뮤텍스 개체를 기다리는 동안 뮤텍스 개체가 중단되었습니다.

하나 이상의 객체가 신호를 보냈기 때문에 대기 함수가 성공하면 스레드가 깨어나 실행을 계속할 수 있습니다. 방금 신호를 받은 객체가 여전히 신호를 받고 있습니까? 객체의 유형에 따라 다릅니다. 프로세스 및 스레드와 같은 일부 개체는 여전히 신호 상태를 유지합니다. 프로세스가 종료되거나 종료되면 신호가 방출되고 남은 수명 동안(프로세스에 열린 핸들이 있는 동안) 그대로 유지됩니다.

일부 유형의 객체는 성공적으로 기다린 후 신호 상태를 변경할 수 있습니다. 예를 들어, 뮤텍스에 대한 성공적인 대기는 이를 신호 없는 상태로 되돌립니다. 신호를 내보낼 때 특별한 동작을 나타내는 또 다른 개체는 자동 재설정 이벤트입니다. 신호가 방출되면 스레드(한 개만)를 해제하고, 이런 일이 발생하면 상태가 자동으로 신호되지 않은 상태로 전환됩니다.

여러 스레드가 동일한 뮤텍스를 기다리고 신호를 보내면 어떻게 되나요? 신호를 받지 않은 상태로 다시 전환되기 전에 하나의 스레드만 뮤텍스를 획득할 수 있습니다. 그 뒤에서 객체의 대기 스레드는 FIFO(선입선출) 대기열에 저장되므로 대기열의 첫 번째 스레드가 우선순위에 관계없이 깨어나는 스레드입니다. 그러나 이 동작에 의존해서는 안 됩니다. 일부 내부 메커니즘으로 인해 스레드가 대기를 중지할 수 있으며(예: 디버거를 사용하는 등 스레드가 일시 중단된 경우) 스레드가 다시 시작되면 대기열 뒤로 푸시됩니다. 따라서 여기서의 간단한 규칙은 어느 스레드가 먼저 깨어날지 결정할 방법이 없다는 것입니다. 이 알고리즘은 향후 Windows 버전에서 언제든지 변경될 수 있습니다.

18.6.3.2 뮤텍스

**뮤텍스(상호 배제의 약어)**는 뮤텍스 잠금, 뮤텍스, 뮤텍스 객체 등으로도 불립니다. 크리티컬 섹션과 유사한 기능을 제공하고 공유 데이터를 동시 접근으로부터 보호합니다. 한 번에 하나의 스레드만 성공적으로 뮤텍스를 획득하고 공유 데이터에 계속 액세스할 수 있습니다. 뮤텍스를 기다리고 있는 다른 모든 스레드는 획득 스레드가 뮤텍스를 해제할 때까지 계속 기다려야 합니다. Windows 관련 API:

cpp
HANDLE CreateMutex(LPSECURITY_ATTRIBUTES lpMutexAttributes, BOOL bInitialOwner, LPCTSTR lpName);
HANDLE CreateMutexEx(LPSECURITY_ATTRIBUTES lpMutexAttributes, LPCTSTR lpName, DWORD dwFlags, DWORD dwDesiredAccess);

HANDLE OpenMutexW(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCWSTR lpName);

BOOL ReleaseMutex(_In_ HANDLE hMutex);

뮤텍스를 소유한 스레드가 어떤 이유로든 종료되거나 종료되면 어떻게 되나요? 뮤텍스의 소유자만이 뮤텍스를 해제할 수 있으므로 교착 상태가 발생할 수 있으며 뮤텍스를 기다리고 있는 다른 스레드는 결코 이를 획득하지 못합니다. 이러한 유형의 뮤텍스를 폐기된 뮤텍스라고 하며 소유자 스레드에 의해 폐기됩니다.

다행스럽게도 커널은 뮤텍스의 소유권을 알고 있으므로 뮤텍스를 보유하는 동안 스레드가 종료되는 것을 발견하면(그렇다면 둘 이상이 있을 것입니다) 커널은 버려진 뮤텍스를 명시적으로 해제합니다. 이로 인해 뮤텍스를 성공적으로 획득한 다음 스레드가 WaitForSingleObject 호출에서 WAIT_OBJECT_0 대신 WAIT_ABANDONED를 다시 가져옵니다. 스레드가 정상적으로 뮤텍스를 획득했지만 특수 반환 값은 이전 소유자가 종료 전에 뮤텍스를 해제하지 않았음을 나타내는 힌트로 사용됨을 의미합니다.

18.6.3.3 세마포어

세마포어는 초기화와 별도로 두 가지 표준 원자 연산(wait() 및 signal())을 통해서만 액세스할 수 있는 정수 변수입니다. wait() 작업은 원래 P라고 불렸고, signal()은 원래 V라고 불렸습니다.

P 및 V 작동 다이어그램. 그림은 하나의 태스크 T0에 의해 실행되는 P() 함수 호출 시퀀스와 동일한 세마포에서 다른 태스크 또는 ISR에 의해 실행되는 V() 함수를 보여줍니다.

범용 동기화 도구로서의 세마포어:

  • 세마포어 계산: 정수 값은 제한되지 않은 필드를 포함할 수 있습니다.

  • 바이너리 세마포어: 정수 값의 범위는 0과 1 사이만 가능합니다.

  • 좀 더 간단하게 구현할 수 있습니다.

  • 뮤텍스라고도 알려진 카운팅 세마포어는 이진 세마포어로 구현될 수 있습니다.

  • 상호 배타적인 세마포어의 상호 배제를 제공합니다.

cpp
// initialized
do 
{
    wait (mutex);
    // Critical Section
    signal (mutex);
} while (TRUE);

세마포어 구현: 두 프로세스가 동시에 동일한 세마포어에서 wait() 및 signal()을 실행할 수 없도록 보장해야 합니다. 따라서 구현은 임계 섹션에 대기 및 신호 코드가 배치되어 임계 섹션 문제가 됩니다. 이제 바쁜 대기 중요 섹션의 구현을 사용할 수 있으며 구현 코드는 매우 짧으며 중요 섹션이 거의 사용되지 않으면 바쁜 대기가 거의 발생하지 않습니다. 애플리케이션이 중요한 섹션에서 많은 시간을 소비할 수 있으므로 이는 좋은 해결 방법이 아닙니다.

바쁜 대기 없는 세마포 구현: 각 세마포에는 연관된 대기 큐가 있고 대기 큐의 각 항목에는 두 개의 데이터 항목(정수 유형의 값)과 목록의 다음 레코드에 대한 포인터가 있습니다. 두 가지 작업이 있습니다. 차단 - 해당 작업을 호출하는 프로세스를 적절한 대기 대기열에 배치합니다. 깨우기 - 대기 큐에서 프로세스를 제거하고 준비 큐에 배치합니다.

cpp
// 구현
wait(semaphore *S) 
{
    S->value--;
    if (S->value < 0) 
    {
        add this process to S->list;
        block();
    }
}

// 신호(Signal)구현
signal(semaphore *S) 
{
    S->value++;
    if (S->value <= 0) 
    {
        remove a process P from S->list;
        wakeup(P);
    }
}

세마포어는 하드웨어에서 지원되지 않지만 몇 가지 매력적인 속성을 가지고 있습니다.

  • 세마포어는 장치 독립적입니다.

  • 세마포어는 구현하기 쉽습니다.

  • 정확성을 판단하기 쉽습니다.

  • 여러 개의 서로 다른 임계 섹션과 서로 다른 세마포가 있을 수 있습니다.

  • 세마포어는 동시에 많은 자원을 획득합니다.

세마포어의 단점:

  • 본질적으로 공유 전역 변수입니다.

  • 세마포어는 프로그램의 어느 곳에서나 접근 가능합니다.

  • 올바른 사용을 통제하거나 보장할 수 없습니다.

  • 세마포어와 세마포어가 액세스를 제어하는 ​​데이터 사이에는 언어 연결이 없습니다.

  • 상호 배제와 일정 제약이라는 두 가지 목적을 제공합니다.

세마포어는 프로세스 동기화를 위한 편리하고 효율적인 메커니즘을 제공하지만 이를 잘못 사용하면 감지하기 어려운 타이밍 오류가 발생할 수 있습니다. 이러한 오류는 특정 실행 시퀀스가 ​​발생할 때만 발생하고 항상 발생하는 것은 아니기 때문입니다.

세마포어 메커니즘의 개략도.

프로세스는 세마포어에 의해 보호되는 공유 데이터에 액세스합니다.

모니터는 세마포어와 동일한 기능을 제공하고 제어하기 더 쉬운 프로그래밍 언어 구조입니다. 모니터 구조는 Concurrent Pascal, Pascal Plus, Modula-2, Modula-3 및 Java를 포함한 다양한 프로그래밍 언어로 구현되었습니다. 추상 데이터 유형(ADT)은 ADT의 특정 구현과 독립적인 해당 데이터에 대한 작업을 위한 일련의 함수로 데이터를 캡슐화합니다. 모니터 유형은 모니터 내에서 상호 배타적인 프로그래머 정의 작업 세트를 포함하는 ADT입니다. 모니터 유형은 또한 해당 유형의 인스턴스 상태를 정의하는 값을 갖는 변수와 이러한 변수에 대해 작동하는 함수 본문을 선언합니다. 또한 프로그래머가 모든 개체에 모니터 잠금을 설정할 수 있는 라이브러리로 구현됩니다. 모니터 구문:

cpp
monitor monitor_name
{
    /* shared variable declarations */
    function P1(...) 
    {
        ...
    }
    function P2 (...) 
    {
        ...
    }
    
    ...
        
    function Pn (...) 
    {
        ...
    }
    initialization code (...) 
    {
        ...
    }
}

따라서 모니터에 정의된 함수는 모니터에 로컬로 선언된 변수와 해당 형식 매개변수에만 액세스할 수 있습니다. 마찬가지로 모니터의 지역 변수는 지역 함수로만 접근할 수 있습니다.

Windows에서 세마포어의 목적은 스레드로부터 안전한 방식으로 무언가를 제한하는 것입니다. 세마포어는 현재 및 최대 개수로 초기화됩니다. 현재 카운트가 0보다 크면 신호 상태에 있습니다. 스레드가 세마포에서 WaitForSingleObject를 호출하고 신호를 받은 상태에 있을 때마다 세마포의 개수가 감소하고 스레드가 계속되도록 허용됩니다. 세마포어 카운트가 0에 도달하면 신호가 해제되고 이를 기다리려는 모든 스레드가 차단됩니다. 대신, 세마포어 수(또는 그 이상)를 "해제"하려는 스레드는 ReleaseSemaphore를 호출하여 세마포어 수를 증가시키고 신호를 받은 상태로 다시 설정합니다.

cpp
HANDLE CreateSemaphore( LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, ...);
HANDLE CreateSemaphoreEx( LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, ...);
BOOL   ReleaseSemaphore( HANDLE hSemaphore, ...);

18.6.3.4 이벤트

어떤 의미에서 이벤트는 가장 간단한 동기화 프리미티브입니다. 이는 설정(신호 발생) 또는 재설정(신호 없음)이 가능한 플래그일 뿐입니다. (이름이 지정될 수도 있는) 커널 개체로서 단일 프로세스 내에서 또는 프로세스 간에 작업할 수 있는 유연성을 가지고 있습니다. 이벤트와 관련된 복잡성은 수동 재설정과 자동 재설정이라는 두 가지 유형의 이벤트가 있다는 것입니다. 아래 표에는 이들의 특성이 요약되어 있습니다.

이벤트 유형 커널 이름 SetEvent 효과

수동 재설정 알림 이벤트를 신호를 받은 상태로 두고 대기 중인 모든 스레드를 해제합니다. 이벤트는 신호를 받은 상태로 유지됩니다.

자동 재설정 동기화 개별 스레드는 대기 상태에서 해제되고 이벤트는 자동으로 신호를 받지 않은 상태로 돌아갑니다.

Windows 관련 API:

cpp
HANDLE CreateEvent( LPSECURITY_ATTRIBUTES lpEventAttributes, BOOL bManualReset, BOOL bInitialState, LPCTSTR lpName);
HANDLE CreateEventEx( LPSECURITY_ATTRIBUTES lpEventAttributes, LPCTSTR lpName, DWORD dwFlags, DWORD dwDesiredAccess);

HANDLE OpenEvent( DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSTR lpName);

BOOL SetEvent(_In_ HANDLE hEvent); // signaled
BOOL ResetEvent(_In_ HANDLE hEvent); // non-signaled

BOOL PulseEvent(_In_ HANDLE hEvent);

18.6.3.5 대기 가능 타이머

Windows API는 다양한 의미와 프로그래밍 모델을 사용하여 여러 타이머에 대한 액세스를 제공합니다. 주요 내용은 다음과 같습니다.

  • 창 시나리오의 경우 SetTimer API는 WM_TIMER 메시지를 호출 스레드의 메시지 큐에 게시하여 작동하는 타이머를 제공합니다. 이 타이머는 타이머 메시지가 UI 스레드에서 처리될 수 있으므로 GUI 애플리케이션에 적합합니다.

  • Windows 멀티미디어 API는 우선 순위 수준 15의 별도 스레드에서 콜백 함수를 호출하는 timeSetEvent로 생성된 멀티미디어 타이머를 제공합니다. 타이머는 일회성이거나 주기적일 수 있으며 매우 정확하며(해상도는 함수에 의해 설정될 수 있음) 시스템이 제공할 수 있는 가장 높은 해상도가 필요한 해상도 값이 0입니다.

관련 API:

cpp
HANDLE CreateWaitableTimer( LPSECURITY_ATTRIBUTES lpTimerAttributes, BOOL bManualReset, LPCTSTR lpTimerName);
HANDLE CreateWaitableTimerEx( LPSECURITY_ATTRIBUTES lpTimerAttributes, LPCTSTR lpTimerName, DWORD dwFlags, DWORD dwDesiredAccess);

HANDLE OpenWaitableTimer( DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSTR lpTimerName);

BOOL SetWaitableTimer( HANDLE hTimer, const LARGE_INTEGER* lpDueTime, LONG lPeriod, PTIMERAPCROUTINE pfnCompletionRoutine, LPVOID lpArgToCompletionRoutine, BOOL fResume);
BOOL SetWaitableTimerEx( HANDLE hTimer, const LARGE_INTEGER* lpDueTime, LONG lPeriod, PTIMERAPCROUTINE pfnCompletionRoutine, LPVOID lpArgToCompletionRoutine, PREASON_CONTEXT WakeContext, ULONG TolerableDelay);

18.6.3.6 메시징

메시징을 사용하면 프로세스가 동일한 주소 공간을 공유하지 않고도 작업을 통신하고 동기화할 수 있습니다. 통신 프로세스가 네트워크로 연결된 여러 컴퓨터에 위치할 수 있는 분산 환경에서 특히 유용합니다. 메시징 기능은 보내기(정보) 및 받기(메시지)라는 두 가지 이상의 작업을 제공합니다. P와 Q가 통신하려면 통신 링크를 설정하고 송수신을 통해 메시지를 교환해야 합니다. 통신 링크 물리학(예: 공유 메모리, 하드웨어 버스) 논리(예: 논리적 특성)를 구현합니다.

직접 통신의 경우 프로세스 이름을 명시적으로 지정해야 합니다.

  • send(P, message): 프로세스 P에게 메시지를 보냅니다.

  • receive(Q, message): 프로세스 Q로부터 메시지를 수신합니다.

통신 링크의 속성. 링크는 자동으로 설정됩니다. 링크는 한 쌍의 통신 프로세스에만 연결됩니다. 각 쌍 사이에는 하나의 링크만 있습니다. 링크는 단방향일 수도 있지만 일반적으로 양방향입니다.

간접 통신의 경우 메일은 사서함(포트라고도 함)에서 전달되고 수신되며 각 사서함에는 고유 ID가 있으며 프로세스는 사서함을 공유하는 경우에만 통신할 수 있습니다. 통신 링크의 속성. 링크는 프로세스가 공통 사서함을 공유하는 경우에만 설정됩니다. 링크는 많은 프로세스와 연결될 수 있습니다. 각 프로세스 쌍은 여러 통신 링크를 공유할 수 있으며 링크는 단방향 또는 양방향일 수 있습니다.

메시지 동기화에 따라 메시지 전달은 차단되거나 차단되지 않을 수 있습니다. 차단은 동기식으로 간주되며 보내기 차단은 메시지가 수신될 때까지 보낸 사람을 차단합니다. 수신을 차단하면 메시지를 사용할 수 있을 때까지 수신자가 차단되고, 비차단은 비동기로 간주되며, 비차단 전송을 사용하면 발신자가 메시지를 보내고 계속 진행되며, 비차단 수신을 통해 수신자는 유효한 메시지 또는 null을 수신하게 됩니다.

링크에 연결된 메시지 대기열은 다음 세 가지 방법 중 하나로 구현됩니다.

  • 용량 0: 발신자가 수신자(수집)를 기다려야 하는 메시지가 0개입니다.

  • 제한된 용량: n개의 메시지 길이가 제한되어 있습니다. 링크가 가득 차면 발신자는 기다려야 합니다.

  • 무제한 용량: 무제한 길이의 발신자는 결코 기다리지 않습니다.

18.6.3.7 대기열

세마포어는 선점형 멀티태스킹을 위한 가장 강력한 데이터 구조를 제공하지만 때때로 명시적으로 사용됩니다. 더 일반적으로는 대기열이라는 다른 데이터 구조에 의해 숨겨져 있습니다. FIFO(선입선출)라고도 알려진 대기열은 Put() 및 Get()이라는 두 가지 이상의 기능을 제공하는 버퍼입니다. 대기열에 저장되는 항목의 크기는 다양할 수 있으므로 대기열은 템플릿 클래스로 구현하는 것이 가장 좋습니다. 항목 수도 다를 수 있으므로 클래스 생성자는 필요한 길이를 인수로 사용합니다.

가장 간단한 형태의 큐는 링 버퍼입니다. 메모리의 인접한 부분(버퍼라고 함)이 할당되고 두 변수 GetIndex 및 PutIndex가 0으로 초기화되어 메모리 공간의 시작을 가리킵니다. GetIndex 및 PutIndex에 대해 수행되는 유일한 작업은 이를 증가시키는 것입니다. 기억의 끝을 지나게 되면 처음으로 재설정됩니다. 마지막에 이 감기는 직선 메모리를 루프로 바꿉니다. GetIndex=PutIndex인 경우에만 버퍼가 비어 있습니다.

그렇지 않으면 PutIndex가 항상 GetIndex보다 앞서게 됩니다(PutIndex의 끝이 래핑되고 GetIndex가 래핑되지 않은 경우 PutIndexe가 GetIndex보다 작을 수 있음). 아래 그림에서는 링 버퍼가 다이렉트 메모리와 논리 링으로 표시됩니다.

잠금 없는 동기화를 달성하기 위해 링 버퍼를 사용하여 세마포어를 넣거나 가져올 수 있습니다.

18.6.3 동기화 문제

18.6.3.1 교착상태

교착 상태의 정의: 다중 프로그래밍 환경에서는 여러 프로세스가 제한된 수의 리소스를 놓고 경쟁할 수 있습니다. 프로세스가 자원을 요청할 때 자원을 사용할 수 없으면 프로세스는 대기 상태에 들어갑니다. 때때로 대기 프로세스는 요청한 리소스가 다른 대기 프로세스에 의해 보유되어 있기 때문에 더 이상 상태를 변경할 수 없습니다. 이러한 상황을 교착 상태라고 합니다.

교착상태 시나리오 다이어그램.

시스템은 여러 프로세스에 분산된 제한된 수의 리소스로 구성될 수 있으며 이러한 리소스는 각각 동일한 인스턴스를 갖는 여러 인스턴스로 나뉩니다. 프로세스는 리소스를 사용하기 전에 리소스를 요청해야 하며, 사용 후에는 리소스를 해제해야 합니다. 지정된 작업을 수행하기 위해 원하는 만큼의 리소스를 요청할 수 있으며, 요청된 리소스의 양은 사용 가능한 총 리소스 수를 초과할 수 없습니다. 프로세스는 다음 순서로만 리소스를 사용할 수 있습니다.

  • 요청: 요청이 즉시 승인되지 않으면 요청 프로세스는 리소스를 얻기 전에 기다려야 합니다.

  • 사용법: 프로세스가 리소스에 대해 작동할 수 있습니다.

  • 해제(Release): 프로세스는 자원을 사용한 후 해제합니다.

교착 상태에는 다양한 유형의 리소스가 포함될 수 있습니다. 예: 프린터와 테이프 드라이브가 있는 시스템을 생각해 보십시오. 프로세스 Pi가 현재 프린터를 보유하고 있으면 프로세스 Pj가 테이프 드라이브를 보유합니다. 프로세스 Pi가 테이프 드라이브를 요청하고 프로세스 Pj가 프린터를 요청하면 교착 상태가 발생합니다. 다중 스레드 프로그램은 공유 리소스를 두고 경쟁하기 때문에 교착 상태에 빠질 가능성이 높습니다.

교착상태의 구체적인 예.

교착 상태를 처리하는 방법은 다음과 같습니다.

  • 프로토콜을 사용하여 교착 상태를 방지하고 시스템이 교착 상태에 빠지지 않도록 합니다.

  • 시스템이 교착 상태에 진입하고 이를 감지하고 복구할 수 있도록 합니다.

  • 문제를 무시하고 시스템에서 교착 상태가 발생하지 않은 것처럼 가장합니다. UNIX를 포함한 대부분의 운영 체제에서는 이 옵션을 사용합니다.

교착 상태가 발생하지 않도록 하기 위해 시스템은 교착 상태 회피 또는 교착 상태 예방을 사용할 수 있습니다. 교착상태 방지는 최소한 하나의 필수 조건이 발생하지 않도록 보장하는 일련의 방법입니다. 교착 상태 방지를 위해서는 운영 체제가 프로세스가 수명 동안 요청하고 사용할 리소스에 대한 정보를 미리 확보해야 합니다. 시스템이 교착상태 회피 또는 교착상태 예방을 사용하지 않으면 교착상태 상황이 발생할 수 있습니다. 이 시간 동안 시스템 상태를 확인하여 교착 상태가 발생했는지 확인하는 알고리즘과 교착 상태를 복구하는 알고리즘을 제공할 수 있습니다. 감지되지 않은 교착 상태는 시스템 성능 저하를 유발합니다.

교착상태의 구체적인 예는 없습니다.

교착상태가 발생하려면 아래의 4가지 필수조건을 모두 만족해야 합니다. 한 가지 조건이 참이 아닌 한 교착 상태가 발생하는 것을 방지할 수 있습니다.

  • 상호 배타적입니다. 한 번에 하나의 프로세스에서만 사용할 수 있는 프린터와 같은 공유할 수 없는 리소스에 적용됩니다. 뮤텍스는 공유 가능한 리소스에 존재할 수 없으므로 교착 상태에 빠질 수 없습니다. 읽기 전용 파일은 공유 리소스의 좋은 예이며 프로세스는 공유 가능한 리소스에 액세스할 때까지 기다리지 않습니다. 그러므로 공유할 수 없는 자원의 상호배제 조건을 거부함으로써 교착상태를 방지할 수는 없다.

  • 예약하고 기다리세요. 프로세스가 리소스를 요청하면(즉, 사용할 수 없는 경우) 프로세스가 보유하고 있는 모든 리소스를 강제로 해제함으로써 이러한 상황을 제거할 수 있습니다. 사용할 수 있는 프로토콜 중 하나는 각 프로세스가 실행을 시작하기 전에 모든 리소스를 할당한다는 것입니다. 예를 들어, 테이프 드라이브에서 디스크로 데이터를 복사하고 파일을 정렬한 다음 결과를 프린터로 인쇄하는 프로세스를 생각해 보세요. 모든 리소스가 처음에 할당되면 테이프 드라이브, 디스크 파일 및 프린터가 프로세스에 할당됩니다. 가장 큰 문제는 프린터가 결국 필요하고 다른 프로세스가 사용할 수 없도록 처음부터 할당되기 때문에 리소스 활용도가 낮다는 것입니다.

사용할 수 있는 또 다른 프로토콜은 프로세스에 리소스가 없을 때 프로세스가 리소스를 요청할 수 있도록 허용하는 것입니다. 예를 들어 프로세스에 테이프 드라이브와 디스크 파일이 할당되고 필요한 작업을 수행하고 둘 다 해제한 다음 프로세스가 다시 디스크 파일과 프린터를 요청하지만 이로 인해 기아 문제가 발생할 수 있습니다.

  • 비선점형. 이런 일이 발생하지 않도록 하려면 리소스를 선점해야 합니다. 다음 프로토콜을 사용할 수 있습니다. 프로세스가 일부 리소스를 보유하고 즉시 할당할 수 없는 다른 리소스를 요청하는 경우 요청 프로세스가 현재 보유하고 있는 모든 리소스는 선점되어 다른 프로세스가 기다리고 있을 수 있는 리소스 목록에 추가됩니다. 요청한 이전 리소스와 새 리소스를 다시 얻은 경우에만 프로세스가 다시 시작됩니다.

프로세스가 리소스를 요청하면 해당 리소스가 사용 가능한지 확인합니다. 사용 가능한 경우 할당하고, 그렇지 않으면 다른 대기 프로세스에 할당되었는지 확인합니다. 그렇다면 대기 프로세스에서 리소스를 선점하여 요청 프로세스에 할당합니다. 요청 프로세스는 기다려야 합니다.

  • 대기주기. 교착 상태의 네 번째이자 마지막 조건은 순환 대기 조건입니다. 이런 일이 발생하지 않도록 하는 한 가지 방법은 모든 리소스 유형의 순서를 적용하고 각 프로세스가 오름차순으로 리소스를 요청하는 것입니다.

교착 상태 방지(개념만 해당): 교착 상태 방지 알고리즘은 장치 활용도를 낮추고 시스템 처리량을 감소시킬 수 있습니다. 교착 상태를 방지하려면 리소스 요청 방법에 대한 추가 정보가 필요합니다. 요청 및 릴리스의 전체 순서를 알면 각 요청에 대한 프로세스를 기다려야 하는지 여부를 결정할 수 있습니다. 각 요청에 대해 현재 사용 가능한 리소스를 확인해야 하며, 현재 각 프로세스에 할당된 리소스는 각 프로세스에 대한 향후 요청을 처리하고 릴리스하여 현재 요청이 충족될 수 있는지 또는 향후 가능한 교착 상태를 피하기 위해 기다려야 하는지 결정합니다. 교착 상태 방지 알고리즘은 리소스 할당 상태를 동적으로 확인하여 순환 대기 조건이 존재하지 않도록 합니다. 자원 할당 상태는 사용 가능하고 할당된 자원 수와 각 프로세스의 최대 요구량에 따라 정의됩니다.

안전 상태(Safe State): 상태는 교착 상태를 일으키지 않고 모든 프로세스가 완전히 실행되는 적어도 하나의 시퀀스가 ​​존재하는 안전한 상태입니다. 안전 시퀀스가 ​​존재하면 시스템은 안전 상태에 있습니다. 각 Pi에 대해 Pi가 요청할 수 있는 자원이 현재 사용 가능한 자원으로 충족될 수 있는 경우 프로세스 시퀀스 <P1, P2,………..Pn>은 현재 할당 상태에 대한 안전한 시퀀스입니다. Pi가 요청한 리소스를 현재 사용할 수 없는 경우 Pi는 지정된 작업을 완료하는 데 필요한 모든 리소스를 얻을 수 있습니다.

안전상태는 교착상태가 아닙니다. 프로세스가 자원(즉, 현재 사용 가능한 자원)을 요청할 때마다 시스템은 자원을 즉시 할당할 수 있는지 아니면 프로세스가 기다려야 하는지를 결정해야 합니다. 할당으로 인해 시스템이 안전한 상태가 된 경우에만 요청이 승인됩니다. 이 경우 프로세스가 현재 사용 가능한 리소스를 요청하면 기다려야 합니다. 따라서 교착상태 회피 알고리즘을 사용하지 않을 때보다 자원 활용도가 낮아질 수 있습니다.

리소스 할당 그래프 알고리즘: 이 알고리즘은 리소스 유형의 인스턴스가 있는 경우에만 사용됩니다. 요청 에지와 할당 에지 외에 클레임 에지라는 새로운 에지가 사용됩니다. 예를 들어, 클레임 에지가 점선으로 표시되는 다음 그림을 고려하면 클레임 에지 Pi->Rj는 프로세스 Pi가 향후 Rj를 요청할 수 있음을 나타냅니다. 프로세스 Pi가 리소스 Rj를 요청하면 클레임 에지가 요청 에지로 변환됩니다. 리소스 Rj가 프로세스 Pi에 의해 해제되면 할당 에지 Rj->Pi가 선언 에지 Pi->Rj로 대체됩니다.

프로세스 Pi가 Rj를 요청할 때 요청 에지 Pi->Rj를 할당 에지 Rj->Pi로 변환해도 사이클이 발생하지 않는 경우에만 요청이 승인됩니다. 루프 감지 알고리즘은 루프를 감지하는 데 사용됩니다. 주기가 없으면 처리할 리소스 할당으로 인해 시스템이 안전한 상태가 됩니다.

뱅커 알고리즘은 각 자원 유형의 다중 인스턴스가 있는 시스템에 적합하지만 자원 할당 그래프 알고리즘보다 효율성이 떨어집니다. 새로운 프로세스가 시스템에 들어갈 때 필요할 수 있는 최대 리소스 수를 선언해야 하며, 이는 시스템의 총 리소스 수를 초과할 수 없습니다. 시스템은 리소스 할당으로 인해 시스템이 안전한 상태가 될지 여부를 결정해야 하며, 그렇다면 프로세스가 충분한 리소스를 해제할 때까지 기다려야 합니다. 은행가 알고리즘을 구현하기 위해 여러 데이터 구조가 사용됩니다. "n"은 시스템의 프로세스 수이고 "m"은 리소스 유형 수입니다. 다음과 같은 데이터 구조가 필요합니다.

  • 사용 가능: 사용 가능한 자원 수를 나타내는 길이 m의 벡터입니다. Available[i]=k이면 자원 유형 Rj의 k 인스턴스를 사용할 수 있습니다.

  • Max: nxm 매트릭스는 각 프로세스의 최대 수요를 정의합니다. Max[i, j]=k이면 Pi는 리소스 유형 Rj의 인스턴스를 최대 k개까지 요청할 수 있습니다.

  • 할당: 현재 각 프로세스에 할당된 각 유형의 자원 수를 정의하는 nxm 매트릭스입니다. Allocation[i, j]=k이면 Pi는 현재 자원 유형 Rj의 k 인스턴스입니다.

  • 요구 사항: nxm 매트릭스는 각 프로세스의 나머지 리소스 요구 사항을 나타냅니다. Need[i, j]=k인 경우 Pi는 작업을 계산하기 위해 리소스 유형 Rj의 k 인스턴스가 필요할 수도 있습니다. 따라서 [i, j] = 최대 [i, j] - 할당 [i]가 필요합니다.

보안 알고리즘은 시스템이 안전한 상태인지 의사 코드로 확인하는 데 사용됩니다.

cpp
// Step 1: Work 및 Finish로 m 및 n의 벡터 ,초기화(Initialize)
Work = Available
For i = 1,2, …, n,
if Allocationi 0, then
    Finish[i] = false;
otherwise, 
    Finish[i] = true

// Step 2: 탐색/조회(Find/Lookup)i,로써
Finish[i] == false
Requesti <= Work
If no such i exists then
    go to step 4

// Step 3:
Work = Work + Allocation
Finish [i] = true
Go to step 2

//Step 4:
If Finish [i] = false
for some i, 1<=i<= n, then 
    the system is in a deadlock state
    Moreover
    if Finish[i] = false then 
        process Pi is deadlocked

안전상태 계산과정.

안전하지 않은 상태 계산.

자원 할당 알고리즘: 요청 = 프로세스 Pi의 요청 벡터. Requesti[j]=k인 경우 Pi를 처리하려면 리소스 유형 Rj의 k 인스턴스가 필요합니다.

1단계: Requesti<=Needi인 경우 2단계로 이동합니다. 그렇지 않으면 프로세스가 선언된 최대값을 초과했기 때문에 오류 조건이 발생합니다.

2단계: Requesti<=를 사용할 수 있으면 3단계로 이동합니다. 그렇지 않으면 리소스를 사용할 수 없기 때문에 Pi는 기다려야 합니다.

3단계: 다음과 같이 상태를 수정하여 요청된 리소스를 Pi에 할당하는 척합니다.

cpp
Available   = Available – Request;
Allocationi = Allocationi + Requesti;
Needi       = Needi – Requesti;

안전하다면 Pi에 리소스가 할당됩니다. 안전하지 않은 경우 Pi는 기다렸다가 이전 리소스 할당 상태를 복원해야 합니다.

교착상태 감지 예시.

교착 상태 복구: 교착 상태 주기가 제거될 때까지 교착 상태에 있는 모든 프로세스를 한 번에 하나씩 중단합니다. 중단하려면 어떤 순서를 선택해야 합니까? 대답은 다음과 같습니다.

  • 프로세스의 우선순위.

  • 프로세스를 계산하는 데 걸리는 시간과 완료되는 시간입니다.

  • 프로세스에서 사용되는 리소스입니다.

  • 리소스 프로세스가 완료되어야 합니다.

  • 얼마나 많은 프로세스를 종료해야 합니까?

  • 프로세스가 대화형인가요 아니면 일괄인가요?

자원 선점: 피해자를 선택하고 비용을 최소화합니다. 롤백 - 안전한 상태로 돌아가서 해당 상태에서 프로세스를 다시 시작합니다. 기아 - 비용 요소의 롤백 수를 포함하여 항상 동일한 프로세스가 피해자로 선택될 수 있습니다.

중요한 섹션을 처리하는 것은 간단해 보입니다. 그러나 다양한 RAII 래퍼를 사용하더라도 여전히 교착 상태의 위험이 있습니다. 일반적인 교착 상태는 잠금 1을 소유한 스레드 A(예: 임계 섹션)가 스레드 B가 소유한 잠금 2를 기다리고 스레드 B가 잠금 1을 기다리는 경우 발생합니다.

교착 상태를 방지하는 또 다른 간단한 방법: 항상 동일한 순서로 잠금을 획득합니다. 즉, 여러 잠금이 필요한 모든 스레드는 항상 동일한 순서로 잠금을 획득해야 하며 교착 상태가 발생하지 않도록 보장합니다(적어도 이러한 잠금으로 인해 발생하지 않음). 실제 질문은 코드를 작성하지 않고 순서를 시행하는 방법입니다. 향후 코드가 계속해서 규칙을 준수하도록 순서를 기록하기만 하면 됩니다. 또 다른 옵션은 항상 동일한 순서로 잠금을 획득하는 다중 잠금 래퍼를 작성하는 것입니다. 이를 수행하는 간단한 방법은 메모리의 잠금 주소로 획득을 명령하는 것입니다.

18.6.3.2 생산자-소비자 문제

생산자 프로세스는 소비자 프로세스에서 사용되는 정보를 생성합니다. 예를 들어, 컴파일러는 어셈블러에서 사용되는 어셈블리 코드를 생성할 수 있습니다. 결과적으로 어셈블러는 로더에서 사용되는 개체 모듈을 생성할 수 있습니다. 생산자-소비자 문제는 클라이언트-서버 패턴에 대한 유용한 비유도 제공합니다.

생산자-소비자 문제에 대한 한 가지 해결책은 공유 메모리를 사용하는 것입니다. 생산자와 소비자 프로세스가 동시에 실행되도록 하려면 생산자가 채우고 소비자가 비울 수 있는 항목 버퍼가 있어야 합니다. 이 버퍼는 생산자와 소비자 프로세스가 공유하는 메모리 영역에 상주합니다. 생산자는 하나의 상품을 생산할 수 있고 소비자는 다른 상품을 소비할 수 있습니다.

소비자가 아직 생산되지 않은 데이터 항목을 소비하려고 시도하지 않도록 생산자와 소비자를 동기화해야 합니다. 두 가지 유형의 버퍼, 즉 무한 버퍼와 유한 버퍼를 사용할 수 있습니다. 무한 버퍼에는 버퍼 크기에 대한 실제 제한이 없으며 소비자는 새 제품을 기다려야 할 수 있지만 생산자는 항상 새 제품을 생산할 수 있습니다. 유한 버퍼는 고정된 버퍼 크기를 가정하며, 이 경우 소비자는 버퍼가 비어 있으면 기다려야 합니다. 버퍼가 가득 차면 생산자는 기다려야 합니다.

제한된 버퍼가 공유 메모리를 사용하여 프로세스 간 통신을 어떻게 보여주는지 자세히 살펴보겠습니다. 다음 변수는 생산자 프로세스와 소비자 프로세스가 공유하는 메모리 영역에 있습니다.

cpp
#define BUFFER SIZE 10
typedef struct 
{
. . .
} item;

item buffer[BUFFER SIZE];
int in = 0;
int out = 0;

공유 버퍼는 in과 out이라는 두 개의 논리적 포인터가 있는 원형 배열로 구현됩니다. in 변수는 버퍼의 다음 빈 위치를 가리키고 변수 out은 버퍼의 첫 번째 완전한 위치를 가리킵니다.

  • in==out인 경우 버퍼는 비어 있습니다.

  • ((in + 1) % BUFFER SIZE) == out인 경우 버퍼가 가득 찼습니다.

생산자 프로세스에는 생산될 새 항목이 저장되는 다음 생성된 지역 변수가 있습니다. 소비자 프로세스에는 다음에 사용할 항목을 저장하는 로컬 변수가 있습니다.

cpp
// 을(를) 활용하여 메모리 의 。
item next produced;
while (true) 
{
    /* produce an item in next produced */
    while (((in + 1) % BUFFER SIZE) == out)
    ; /* do nothing */
    buffer[in] = next produced;
    in = (in + 1) % BUFFER SIZE;
}

// 을(를) 활용하여 메모리 의 .
item next consumed;
while (true) 
{
    while (in == out)
    ; /* do nothing */
    next consumed = buffer[out];
    out = (out + 1) % BUFFER SIZE;
    /* consume the item in next consumed */
}

생산자와 소비자 문제(세마포어)를 구현하는 C 프로그램을 작성하세요.

  • 세마포어 뮤텍스를 전체 및 비어 있는 상태로 초기화합니다.

  • 생산자 프로세스의 경우:

임시 변수에 항목을 생성합니다.

  • 버퍼에 빈 공간이 있는 경우 뮤텍스 값을 확인하여 크리티컬 섹션으로 진입합니다.

  • 뮤텍스 값이 0이면 생산자는 임시 변수의 값을 버퍼에 추가할 수 있습니다.

  • 소비자 프로세스의 경우:

버퍼가 비어 있으면 기다려야 합니다.

  • mutex 값의 버퍼 검사에 항목이 있는 경우, mutex == 0이면 해당 데이터 항목을 버퍼에서 제거합니다.

  • 뮤텍스 값을 신호하고 null 값을 1씩 감소시킵니다.

  • 소비 데이터 항목.

  • 결과를 출력합니다.

해당 샘플 코드:

cpp
#include<stdio.h>

int mutex=1, full=0, empty=3, x=0; 
int n;

void producer();
void consumer();
int wait(int);
int signal(int);

void main()
{
    printf("\n 1.producer\n2.consumer\n3.exit\n"); 
    
    while(1)
    {
        printf(" \nenter ur choice");
        scanf("%d",&n);
        switch(n)
        {
        case 1: 
            if((mutex==1)&&(empty!=0))
                producer();
            else
                printf("buffer is full\n");
            break;
        case 2: 
            if((mutex==1)&&(full!=0))
                consumer();
            else
                printf("buffer is empty");
            break;
        case 3: 
            exit(0);
            break;
        }
    }
}

int wait(int s)
{
    return(--s);
}

int signal(int s)
{
    return (++s);
}

void producer()
{
    mutex = wait(mutex);
    full = signal(full);
    empty = wait(empty);
    x++;
    printf("\n producer produces the items %d", x); 
    mutex = signal(mutex);
}

void consumer()
{
    mutex = wait(mutex);
    full = wait(full);
    empty = signal(empty);
    printf("\n consumer consumes the item %d", x);
    x--;
    mutex = signal(mutex);
}

18.6.3.3 클래식 IPC 문제

고전적인 IPC 문제는 새로 제안된 거의 모든 동기화 방식을 테스트하는 데 사용됩니다. 이러한 문제를 해결하기 위한 우리의 접근 방식에서는 동기화를 위한 세마포어를 사용합니다. 이것이 그러한 솔루션을 제시하는 전통적인 방법이기 때문입니다. 그러나 이러한 솔루션의 실제 구현에서는 바이너리 세마포어 대신 뮤텍스 잠금을 사용할 수 있습니다.

제한된 버퍼 문제

이는 동기화 프리미티브의 힘을 설명하기 위해 자주 사용됩니다. 우리 문제에서 생산자와 소비자 프로세스는 다음과 같은 데이터 구조를 공유합니다.

cpp
int n;
semaphore mutex = 1;
semaphore empty = n;
semaphore full = 0

풀이 n개의 버퍼로 구성되어 있고 각 버퍼는 하나의 항목을 보유할 수 있다고 가정합니다. 뮤텍스 세마포어는 버퍼 풀에 대한 액세스에 대한 상호 배제를 제공하며 값 1로 초기화됩니다. 빈 세마포어 및 전체 세마포어는 빈 버퍼 및 전체 버퍼의 수를 계산합니다. 빈 세마포는 n 값으로 초기화되고 세마포 가득 찬 세마포는 0 값으로 초기화됩니다.

cpp
// 의 구조체
do 
{
    (...)
    /* produce an item in next produced */
    (...)
    wait(empty);
    wait(mutex);
    (...)
    /* add next produced to the buffer */
    (...)
    signal(mutex);
    signal(full);
} while (true);

// 의 결과
do {
    wait(full);
    wait(mutex);
    (...)
    /* remove an item from buffer to next consumed */
    (...)
    signal(mutex);
    signal(empty);
    (...)
    /* consume the item in next consumed */
    (...)
} while (true);

리더-라이터 문제

데이터베이스가 여러 동시 프로세스에서 공유된다고 가정합니다. 이러한 프로세스 중 일부는 데이터베이스에서만 읽기를 원하는 반면, 다른 프로세스는 데이터베이스를 업데이트(즉, 읽기 및 쓰기)하기를 원할 수 있습니다. 두 명의 리더가 동시에 공유 데이터에 액세스하는 경우 부작용이 없습니다. 작성자와 다른 프로세스(리더 또는 작성자)가 동시에 데이터베이스에 액세스하는 경우 문제가 발생할 수 있습니다. 작성자가 기다리고 있다고 해서 다른 리더가 완료될 때까지 리더가 기다려서는 안 됩니다.

작성기가 준비되면 작성기는 가능한 한 빨리 쓰기를 수행합니다. 두 가지 문제에 대한 해결책은 기아로 이어질 수 있습니다. 첫 번째 경우에는 작가가 굶어 죽을 수도 있습니다. 두 번째 경우에는 독자가 굶어 죽을 수도 있습니다. 첫 번째 기록기 문제를 해결할 때 기록기 프로세스는 다음과 같은 데이터 구조를 공유합니다.

cpp
semaphore rw mutex = 1;
semaphore mutex = 1;
int read count = 0;

세마포어 rw_mutex는 판독기 및 기록기 프로세스 모두에 공통입니다. 뮤텍스 세마포어는 변수 읽기 횟수를 업데이트할 때 상호 배제를 보장하는 데 사용됩니다. read_count 변수는 현재 객체를 읽고 있는 프로세스 수를 추적합니다. 세마포어 rw_mutex는 작성자의 뮤텍스 세마포어로 사용됩니다.

cpp
// 기록/쓰기(Write)코드
do 
{
    wait(rw mutex);
    (...)
    /* writing is performed */
    (...)
    signal(rw mutex);
} while (true);

// 로드/읽기(Read)코드
do 
{
    wait(mutex);
    read count++;
    if (read count == 1)
        wait(rw mutex);
    signal(mutex);
    (...)
    /* reading is performed */
    (...)
    wait(mutex);
    read count--;
    if (read count == 0)
        signal(rw mutex);
    signal(mutex);
} while (true);

작성자가 임계 섹션에 있고 n명의 판독기가 대기 중인 경우 rw_mutex에 대기열에 있는 판독기 한 명과 뮤텍스에 대기열에 있는 판독기 n-1개가 있습니다. 작성기가 signal(rw_mutex)을 실행하면 스케줄러의 선택에 따라 대기 중인 판독기 또는 단일 대기 작성자로 진행할 수 있습니다.

식사 철학자 질문

식사-철학자 문제는 고전적인 동기화 문제로 간주됩니다.

평생을 생각하고 먹으면서 살았던 다섯 명의 철학자를 생각해 보십시오. 철학자들은 원탁을 공유하고 그 주위에 5개의 의자가 있으며, 각 의자는 철학자의 것입니다. 테이블 중앙에는 밥그릇이 있고, 테이블 위에는 5개의 젓가락이 놓여 있습니다.

철학자는 생각할 때 동료들과 교류하지 않습니다. 때때로 철학자는 배가 고파서 자신에게 가장 가까운 두 개의 젓가락(왼쪽과 오른쪽에 있는 이웃과 자신 사이의 젓가락)을 집으려고 합니다. 철학자는 한 번에 젓가락 하나만 집을 수 있고, 이웃이 쥐고 있는 젓가락은 집을 수 없습니다. 배고픈 철학자는 동시에 두 개의 젓가락을 가지고 있으면 식사를 하는 동안에도 젓가락을 놓지 않을 것입니다. 식사를 마친 그는 젓가락 두 개를 내려놓고 다시 생각하기 시작했습니다.

철학자는 세마포어에 대해 wait() 연산을 수행하여 젓가락을 잡으려고 하고, 적절한 세마포어에 대해 signal() 연산을 수행하여 젓가락을 놓으려고 합니다. 따라서 공유 데이터는 젓가락의 모든 요소가 1로 초기화되는 곳입니다.

cpp
semaphore chopstick[5];
do 
{
    wait(chopstick[i]);
    wait(chopstick[(i+1) % 5]);
    (...)
    /* eat for awhile */
    (...)
    signal(chopstick[i]);
    signal(chopstick[(i+1) % 5]);
    (...)
    /* think for awhile */
    (...)
} while (true);

위의 이 솔루션은 두 이웃이 동시에 식사를 하지 않도록 보장하지만 교착 상태의 가능성 때문에 거부되어야 합니다. 다섯 명의 철학자가 동시에 배가 고프고, 각자 왼쪽에 있는 젓가락을 잡고 있다고 가정해 보겠습니다. 이 시점에서 젓가락의 모든 요소는 이제 0과 같습니다. 모든 철학자는 올바른 젓가락을 잡으려고 시도하면서 영원히 지연될 것입니다. 교착 상태 문제를 해결하는 몇 가지 방법은 다음과 같습니다.

  • 동시에 테이블에 앉을 수 있는 철학자는 최대 4명입니다.

  • 철학자는 두 젓가락을 모두 사용할 수 있을 때만 젓가락을 집는 것이 허용됩니다(이를 위해 철학자는 임계 영역에서 젓가락을 집어야 합니다).

  • 비대칭 솔루션을 사용합니다. 즉, 홀수 철학자는 왼쪽 젓가락을 먼저 집은 다음 오른쪽 젓가락을 집고, 짝수 철학자는 오른쪽 젓가락을 집은 다음 왼쪽 젓가락을 집습니다.

18.6.4 동기화 요약

위에서 언급한 동기화 방법 외에도 Windows는 다른 많은 방법도 제공합니다. 전체 목록은 다음과 같습니다.

-비동기 기능

  • 조건변수 및 SRW 잠금 기능

  • 크리티컬 섹션 기능

-이벤트 기능

-일회성 초기화 기능

  • 연동 기능

-뮤텍스 기능

-개인 네임스페이스 기능

  • 세마포어 기능

  • 단일 연결 리스트 함수

-동기화 장벽 기능

  • 타이머-큐 타이머 기능

  • 대기 기능

  • 대기 타이머 기능

또한 C++ 표준 라이브러리는 특히 크로스 플랫폼 코드의 경우 Windows API의 대안으로 사용할 수 있는 동기화 기본 형식도 제공합니다. 일반적으로 이러한 객체에는 다음과 같이 사용자 정의가 매우 제한되어 있습니다.

  • std::mutex: 중요한 섹션처럼 작동하며 재귀적 획득을 지원하지 않습니다.

  • std::recursive_mutex: 핵심 섹션처럼 작동합니다(재귀적 획득 지원).

  • std::shared_mutex: SRW 잠금과 유사합니다.

  • std::condition_variable: 조건 변수와 동일합니다.

  • 다른.

분명히 대기 주소나 동기화 장벽과 같이 C++에 누락된 부분이 있을 수 있지만 나중에 표준에 추가할 수 있습니다. 어쨌든 모든 C++ 표준 라이브러리 유형은 동일한 프로세스 내에서만 작동하며 프로세스 간에는 사용할 수 없습니다. C++의 더 많은 동기화 기술에 대해서는 2.1.3.3 C++ 다중 스레드 동기화를 참조하세요.

18.7 스레드 고급 테마

18.7.1 스레드 로컬 저장소

스레드는 스택 데이터에 액세스하고 광범위한 전역 변수를 처리할 수 있습니다. 그러나 때로는 일정한 방식으로 액세스할 수 있는 스레드 기반의 일부 저장소를 갖는 것이 편리합니다. 전형적인 예는 익숙한 GetLastError 함수입니다. 모든 스레드가 GetLastError를 호출할 수 있지만 결과는 액세스된 스레드마다 다릅니다. 이 상황을 처리하는 한 가지 방법은 스레드 ID로 키가 지정된 해시 테이블을 저장한 다음 해당 키를 기반으로 값을 찾는 것입니다. 실행 가능하지만 몇 가지 단점이 있습니다. 첫째, 여러 스레드가 동시에 액세스할 수 있으므로 해시 테이블을 동기화해야 합니다. 둘째, 올바른 스레드 검색이 예상만큼 빠르지 않을 수 있습니다.

**TLS(스레드 로컬 저장소)**는 멀티스레드 기반으로 데이터를 저장할 수 있는 사용자 모드 메커니즘입니다. 프로세스의 각 스레드는 액세스할 수 있지만 자체 데이터에만 액세스할 수 있습니다. 그러나 액세스 방법은 통일되어 있습니다.

TLS 사용의 또 다른 전형적인 예는 C/C++ 표준 라이브러리입니다. C 표준 라이브러리는 멀티스레딩이라는 개념이 존재하기 전인 1970년대 초반에 고안되었습니다. 따라서 C 런타임은 전역 변수 집합을 특정 작업의 상태로 유지합니다. 예를 들어 다음 클래식 C 코드는 파일을 열고 가능한 오류를 처리하려고 시도합니다.

cpp
FILE* fp = fopen("somefile.txt", "r");
if(fp == NULL) 
{
    // something went wrong
    switch(errno) 
    {
        case ENOENT: // no such file
        break;
        case EFBIG:
        break;
    }
}

모든 I/O 오류는 전역 errno 변수에 반영됩니다. 하지만 멀티스레드 애플리케이션에서는 문제가 됩니다. 스레드 1이 I/O 함수 호출을 수행하여 errno가 변경되었다고 가정합니다. 값을 확인하기 전에 스레드 2도 I/O 호출을 수행하여 errno를 다시 변경하여 스레드 1이 스레드 2의 활동으로 인해 값을 확인하도록 합니다.

해결책은 errno가 전역 변수가 될 수 없다는 것입니다. 오늘의 errno는 변수가 아니라 스레드 로컬 저장소를 사용하여 현재 스레드의 값을 검색하는 errno() 함수를 호출하는 매크로입니다. 마찬가지로, I/O 함수(예: fopen) 구현에서는 TLS를 사용하여 오류 결과를 현재 스레드에 저장합니다.

18.7.1.1 동적 TLS

Windows API는 TLS 사용을 위해 다음 인터페이스를 제공합니다.

cpp
DWORD  TlsAlloc();
BOOL   TlsFree(DWORD dwTlsIndex);

BOOL   TlsSetValue(DWORD dwTlsIndex, PVOID pTlsValue);
PVOID  TlsGetValue(DWORD dwTlsIndex);

TlsAlloc 함수는 사용 가능한 슬롯 인덱스를 반환하고 모든 기존 스레드에 대해 해당 셀을 모두 0으로 만듭니다. TLS는 DLL에 유용합니다. DLL이 스레드별로 일부 정보를 저장하여 로드 시 많은 양의 정보가 할당되어 필요할 때 사용되기 때문입니다.

TLS의 각 셀은 포인터 크기의 값이므로 여기서 가장 좋은 방법은 단일 슬롯을 사용하고 TLS에 저장하는 데 필요한 모든 정보를 보유하는 데 필요한 모든 구조를 동적으로 할당하고 슬롯 자체의 데이터에 대한 포인터를 저장하는 것입니다. 인덱스를 사용할 수 있게 되면 TlsSetValue, TlsGetValue를 사용하여 슬롯에서 값을 저장하거나 검색합니다.

이러한 함수를 호출하는 스레드는 특정 슬롯 인덱스에 있는 자체 값에만 액세스할 수 있습니다. 다른 스레드의 TLS 슬롯에 직접 액세스할 수 있는 방법은 없습니다. 그렇게 하면 TLS의 목적이 무산되기 때문입니다. 이는 또한 하나의 스레드만 메모리의 동일한 주소에 액세스할 수 있으므로 TLS에 액세스할 때 동기화가 필요하지 않음을 의미합니다. TLS 배열은 아래 이미지에 표시되어 있습니다.

18.7.1.2 정적 TLS

스레드 로컬 스토리지는 전역 또는 정적 변수에 대한 Microsoft 확장 키워드를 사용하거나 C++11 이상 컴파일러를 사용하여 더 간단한 형식으로도 제공됩니다. Windows에는 다음과 같은 두 가지 정의 방법이 있습니다.

cpp
// 1:Microsoft
__declspec(thread) int counter;

// 2:C++정의
thread_local int counter;

이 TLS는 "정적"이므로 할당이 필요하지 않으며 삭제할 수 없습니다. 내부적으로 컴파일러는 모든 스레드 로컬 변수를 청크로 묶고 해당 정보를 PE의 .tls라는 섹션에 저장합니다. 프로세스가 시작될 때 이 정보를 읽는 로더(NTDLL)는 TlsAlloc을 호출하여 슬롯을 할당하고 모든 스레드 로컬 변수가 포함된 메모리 블록을 시작하는 각 스레드에 대해 이를 동적으로 할당합니다. 각 사용자 모드 스레드는 CreateThread에 전달된 "실제" 함수를 호출하기 전에 NTDLLProved 함수에서 시작됩니다.

18.7.2 원격 스레드

Windows에서는 CreateThread 함수를 사용하여 현재 프로세스에 스레드를 생성할 수 있습니다. 그러나 어떤 경우에는 한 프로세스가 다른 프로세스 내에 스레드를 생성하려고 할 수도 있습니다. 대표적인 예가 디버거입니다. 사용자가 "Break" 버튼을 누를 때와 같이 중단점을 강제로 적용해야 하는 경우 디버거는 대상 프로세스에 스레드를 생성하고 이를 DebugBreak 함수(또는 중단 명령을 발행하는 CPU 내부 함수)를 가리키도록 하여 프로세스를 중단하고 디버거에 알립니다. 다음 API는 원격 스레드를 생성할 수 있습니다.

cpp
HANDLE WINAPI CreateRemoteThread(HANDLE hProcess, ...);
HANDLE CreateRemoteThreadEx(HANDLE hProcess, ...);

원격 프로시저 호출(RPC)을 구현하기 위해 스레드를 사용하는 개략도는 다음과 같습니다.

18.7.3 캐싱 및 캐시 라인

마이크로프로세서 초기에는 CPU의 속도가 메모리(RAM)의 속도와 비슷했습니다. 그러면 CPU 속도가 증가하고 메모리 속도가 느려지므로 메모리가 값을 읽거나 쓸 때까지 기다리는 동안 CPU가 많이 정지됩니다. 이를 보완하기 위해 아래 그림과 같이 CPU와 메모리 사이에 캐시가 도입됩니다.

캐시 및 메모리 구조 범례.

캐시는 메인 메모리에 비해 CPU의 정지 시간을 줄이는 빠른 메모리입니다. 캐시는 메인 메모리만큼 크지는 않지만 오늘날의 시스템에서는 그 존재가 필수적이며 그 중요성은 자명합니다. 구체적인 예를 들어보세요. 행렬의 합을 계산하는 두 가지 방법은 다음과 같습니다.

cpp
long long SumMatrix1(const Matrix<int>& m) 
{
    long long sum = 0;
    // (Row major)
    for (int r = 0; r < m.Rows(); ++r)
        for (int c = 0; c < m.Columns(); ++c)
            sum += m[r][c];
    
    return sum;
}

long long SumMatrix2(const Matrix<int>& m) 
{
    long long sum = 0;
    // (Col Major)
    for (int c = 0; c < m.Columns(); ++c)
        for (int r = 0; r < m.Rows(); ++r)
            sum += m[r][c];
    
    return sum;
}

Matrix<> 클래스는 1차원 배열에 대한 간단한 래퍼입니다. 알고리즘 관점에서 두 함수의 행렬 요소를 합산하는 데 필요한 시간은 동일해야 합니다. 결국 코드는 모든 행렬 요소를 한 번에 반복합니다. 그러나 실제 결과는 놀라울 수도 있습니다. 다양한 행렬 크기와 요소별 합계(모두 단일 스레드)를 실행하는 데 걸리는 시간은 다음과 같습니다.

cpp
Type        Size           Sum                Time (nsec)
-----------------------------------------------------------------
Row major   256 X 256      2147516416         34 usec
Col Major   256 X 256      2147516416         81 usec
Row major   512 X 512      34359869440        130 usec
Col Major   512 X 512      34359869440        796 usec
Row major   1024 X 1024    549756338176       624 usec
Col Major   1024 X 1024    549756338176       3080 usec
Row major   2048 X 2048    8796095119360      2369 usec
Col Major   2048 X 2048    8796095119360      43230 usec
Row major   4096 X 4096    140737496743936    8953 usec
Col Major   4096 X 4096    140737496743936    190985 usec
Row major   8192 X 8192    2251799847239680   35258 usec
Col Major   8192 X 8192    2251799847239680   1035334 usec
Row major   16384 X 16384  36028797153181696  142603 usec
Col Major   16384 X 16384  36028797153181696  4562040 usec

캐시가 있기 때문에 차이가 상당히 큽니다. CPU가 데이터를 읽을 때 단일 정수나 읽도록 지시된 데이터를 읽지 않고 대신 전체 캐시 라인(보통 64바이트)을 읽어 내부 캐시에 넣습니다. 그런 다음 메모리의 다음 정수를 읽을 때 정수가 이미 캐시에 있으므로 메모리 액세스가 필요하지 않습니다. 이것이 최적이며 SumMatrix1이 작동하는 방식입니다. 즉, 메모리를 선형적으로 순회합니다. 반면에 SumMatrix2는 하나의 정수(및 캐시 라인의 나머지 부분)를 읽고 다음 정수는 더 멀리 있는 다른 캐시 라인에 있으므로(가장 작은 행렬을 제외하고) 또 다른 캐시 라인을 읽어야 하며 곧 필요할 수 있는 데이터를 버려 상황을 더욱 악화시킬 수 있습니다.

기술적으로 대부분의 CPU에는 3가지 캐시 수준이 구현되어 있습니다. 캐시가 프로세서에 가까울수록 속도는 빨라지지만 용량은 작아집니다. 아래 그림은 4코어 CPU(하이퍼스레딩 기술 포함)의 일반적인 캐시 구성을 보여줍니다.

레벨 1 캐시는 각 논리 프로세서에 하나씩 **데이터 캐시(D-cache)**와 **명령어 캐시(I-cache)**로 구성됩니다. 그런 다음 동일한 코어에 속한 논리 프로세서가 공유하는 레벨 2 캐시가 있습니다. 마지막으로 레벨 3 캐시는 시스템 전체에 적용됩니다. 이러한 캐시의 크기는 매우 작으며, 주 메모리보다 약 3배 정도 작습니다. 아래 이미지와 같이 시스템의 캐시 크기는 작업 관리자의 성능/CPU 탭에서 쉽게 확인할 수 있습니다.

위 그림에서 레벨 3 캐시 크기는 16MB(시스템 전체)이고 레벨 2 캐시 크기는 4MB이지만 모든 코어를 포함합니다. 시스템에는 8개의 코어가 있으므로 각 L2 캐시는 실제로 4MB/8 = 512KB입니다. 마찬가지로 레벨 1 캐시 크기는 640KB이며 16개의 논리 프로세서에 분산되어 각 캐시가 640KB/16 = 40KB가 됩니다. 캐시 크기는 기가바이트 단위의 주 메모리 크기에 비해 작습니다.

캐시, 메인 메모리 구조.

캐시 읽기 작업 과정.

캐시와 캐시 라인이 중요한(심지어 결정적인) 역할을 하는 또 다른 예를 살펴보겠습니다. 다음 예제에서는 대규모 배열을 반복하고 배열에서 짝수를 계산하는 방법을 보여줍니다. 이는 여러 스레드를 통해 수행됩니다. 각 스레드에는 배열의 연속 부분이 할당됩니다. 카운트 자체는 다른 배열에 배치되고 각 셀은 해당 스레드에 의해 수정됩니다. 아래 이미지는 4개의 스레드로 구성된 배열을 보여줍니다.

짝수 계산의 첫 번째 버전은 다음과 같습니다.

cpp
using namespace std;
struct ThreadData 
{
    long long start, end;
    const int* data;
    long long* counters;
};

long long CountEvenNumbers1(const int* data, long long size, int nthreads) 
{
    auto counters_buffer = make_unique<long long[]>(nthreads);
    auto counters = counters_buffer.get();
    auto tdata = make_unique<ThreadData[]>(nthreads);
    
    long long chunk = size / nthreads;
    vector<wil::unique_handle> threads;
    vector<HANDLE> handles;
    
    for (int i = 0; i < nthreads; i++) 
    {
        long long start = i * chunk;
        long long end = i == nthreads - 1 ? size : ((long long)i + 1) * chunk;
        auto& d = tdata[i];
        d.start = start;
        d.end = end;
        d.counters = counters + i;
        d.data = data;
        
        wil::unique_handle hThread(::CreateThread(nullptr, 0, [](auto param) -> DWORD 
        {
            auto d = (ThreadData*)param;
            auto start = d->start, end = d->end;
            auto counters = d->counters;
            auto data = d->data;
            
            for (; start < end; ++start)
                if (data[start] % 2 == 0)
                    ++(*counters);
            return 0;
        }, tdata.get() + i, 0, nullptr));
        
        handles.push_back(hThread.get());
        threads.push_back(move(hThread));
    }
    
    ::WaitForMultipleObjects(nthreads, handles.data(), TRUE, INFINITE);
    
    long long sum = 0;
    for (int i = 0; i < nthreads; i++)
        sum += counters[i];
    
    return sum;
}

루프를 시작하여 각 스레드에 대한 데이터를 준비하고 CreateThread를 호출하여 스레드 루프를 시작하고 스레드 루프를 확인합니다.

cpp
for (; start < end; ++start)
    if (data[start] % 2 == 0)
        ++(*counters);

모든 짝수에 대해 카운터 포인터의 내용은 1씩 증가합니다. 여기에는 데이터 경합이 없습니다. 각 스레드에는 자체 단위가 있으므로 최종 결과는 정확해야 합니다. 이 코드의 문제점은 스레드가 단일 카운트를 쓸 때 전체 캐시 라인을 작성하여 해당 메모리를 보고 있는 다른 프로세서의 모든 캐시를 무효화하고 주 메모리에서 다시 읽어 캐시를 플러시하도록 강제하여 성능이 매우 느려진다는 것입니다. 이러한 상황을 허위 공유라고 합니다.

또 다른 접근 방식은 다른 스레드와 캐시 라인을 공유하는 유닛에 쓰지 않는 것입니다. 적어도 자주는 아닙니다.

cpp
// 의 내에서 데이터 로컬 변수count
size_t count = 0;
for (; start < end; ++start)
    if (data[start] % 2 == 0)
        count++;
*(d->counters) = count;

주요 차이점은 카운트를 지역 변수 count에 유지하고 루프가 완료될 때 한 번만 결과 배열의 셀에 쓰는 것입니다. 카운트는 스레드의 스택에 있고 스택 크기는 최소 4KB이므로 다른 스레드의 다른 카운트 변수와 동일한 캐시 라인에 있을 수 없으므로 성능이 크게 향상됩니다. 물론 로컬 변수를 사용하는 것이 메모리에 간접적으로 액세스하는 것보다 더 빠른 경우가 많습니다. 왜냐하면 컴파일러가 변수를 레지스터에 캐시하는 것이 더 쉽기 때문입니다. 그러나 실제 영향은 스레드 간에 캐시 라인을 공유하지 않는 것입니다. 기본 함수는 다음과 같이 서로 다른 수의 스레드를 사용하여 두 구현을 모두 테스트합니다.

cpp
Initializing data...

Option 1
1 threads count: 2147483647 time: 4843 msec
2 threads count: 2147483647 time: 3391 msec
3 threads count: 2147483647 time: 2468 msec
4 threads count: 2147483647 time: 2125 msec
5 threads count: 2147483647 time: 2453 msec // 스레드(Thread)성능 !!
6 threads count: 2147483647 time: 1906 msec
7 threads count: 2147483647 time: 2109 msec // 스레드(Thread)성능 !!
8 threads count: 2147483647 time: 2532 msec // 스레드(Thread)성능 !!

Option 2
1 threads count: 2147483647 time: 4046 msec
2 threads count: 2147483647 time: 2313 msec
3 threads count: 2147483647 time: 1625 msec
4 threads count: 2147483647 time: 1328 msec
5 threads count: 2147483647 time: 1062 msec
6 threads count: 2147483647 time: 953 msec
7 threads count: 2147483647 time: 859 msec
8 threads count: 2147483647 time: 855 msec

옵션 1에서는 경우에 따라 스레드가 많아지면 성능이 저하될 수 있습니다. 옵션 2에서는 스레드 수가 증가함에 따라 성능이 계속 향상됩니다.

  • 캐싱 성능

주 메모리 캐시 메커니즘은 컴퓨터 아키텍처의 일부이며 하드웨어에 구현되며 일반적으로 운영 체제에는 보이지 않습니다. 그러나 지역성의 속성을 활용하고 적어도 부분적으로 운영 체제에 구현되는 2단계 메모리 접근 방식의 두 가지 예는 가상 메모리와 디스크 캐시입니다(아래 표). 세 가지 접근 방식 모두에 공통적인 2단계 메모리의 몇 가지 성능 특성을 살펴보겠습니다.

메인 메모리 캐시 가상 메모리(페이징) 디스크 캐시

정규 접속 시간 비율 5:1 106 : 1 106 : 1

메모리 관리 시스템 특수 하드웨어로 구현 하드웨어와 시스템 소프트웨어의 결합 시스템 소프트웨어

일반 블록 크기 4~128바이트 64 - 4096바이트 64 - 4096바이트

프로세서 액세스 보조 모드 직접 액세스 간접적인 접근 간접적인 접근

아래 표는 여러 연구자가 다양한 언어를 실행하는 동안 다양한 명령문 유형의 출현을 측정합니다.

주장과 관련하여 PATT85에 보고된 연구는 통화 반환 동작을 보여주는 확인(아래 이미지)을 제공합니다. 각 통화는 오른쪽 아래로 이동하는 행으로 표시되고, 각 통화는 오른쪽 위로 이동하는 행으로 표시됩니다. 그림에서는 깊이가 5인 윈도우가 정의되어 있습니다. 어떤 방향으로든 순 이동이 6인 일련의 호출과 반환만 창을 이동하게 합니다. 그림에서 볼 수 있듯이 실행 중인 프로그램은 오랫동안 고정된 창 안에 머물 수 있습니다.

프로그램 호출은 행동 샘플을 반환합니다.

문헌에서는 공간적 지역성과 시간적 지역성을 구분합니다. 공간적 지역성은 프로세서가 명령에 순차적으로 액세스하는 경향을 반영하여 여러 개의 집계된 메모리 위치를 포함하는 실행 경향을 나타냅니다. 공간적 지역성은 또한 데이터 테이블을 처리할 때와 같이 프로그램이 데이터 위치에 순차적으로 액세스하는 경향을 반영합니다. 시간적 지역성은 프로세서가 최근에 사용된 메모리 위치에 액세스하는 경향을 나타냅니다. 예를 들어 반복 루프를 실행할 때 프로세서는 동일한 명령 집합을 반복적으로 실행합니다.

전통적으로 최근 사용된 명령어와 데이터 값을 캐시 메모리에 보관하고 캐시 계층을 활용하는 방식으로 시간적 지역성을 활용해 왔습니다. 공간적 집약성은 더 큰 캐시 블록을 사용하고 캐시 제어 논리에 프리패치 메커니즘(사용될 것으로 예상되는 항목 가져오기)을 도입하여 활용되는 경우가 많습니다. 최근에는 더 높은 성능을 달성하기 위해 이러한 기술을 개선하기 위한 상당한 연구가 있었지만 기본 전략은 동일하게 유지되었습니다.

상위 레벨 메모리(M1)가 하위 레벨 메모리(M2)보다 더 작고, 빠르며, 더 비싼(비트당) 두 가지 레벨의 메모리를 형성할 때 지역성을 활용할 수 있습니다. MI는 더 큰 M2의 일부를 위한 임시 저장소로 사용됩니다. 메모리 참조가 이루어지면 MI의 항목에 액세스하려고 시도하고, 빠른 액세스가 성공하면 메모리 위치 블록이 M2에서 MI로 복사된 다음 MI를 통해 액세스됩니다. 지역성으로 인해 블록이 도입되면 블록 내 위치에 대한 다중 액세스가 있어야 전체 서비스가 빨라집니다. 항목에 액세스하는 데 걸리는 평균 시간을 표현하려면 두 가지 메모리 수준의 속도뿐만 아니라 다음에서 주어진 참조를 찾을 확률도 고려해야 합니다.

Ts=H×T1+(1H)×(T1+T2)=T1+(1H)×T2

그 중에는:

  • Ts = 평균(시스템) 액세스 시간.

  • T1 = M1의 액세스 시간(캐시, 디스크 캐시 등).

  • T2 = M2(메인 메모리, 디스크 등)의 액세스 시간.

  • H = 적중률(M1에서 발견된 시간 의존 비율).

아래 그래프는 적중률에 따른 평균 액세스 시간을 보여줍니다. 적중률이 높을수록 평균 총 접속 시간이 M2보다 가까운 것을 알 수 있다.

다음 공식을 사용하여 먼저 성능을 고려하여 2단계 메모리 메커니즘과 관련된 일부 매개변수를 평가해 보겠습니다.

Cs=C1S1+C2S2S1+S2

그 중에는:

  • Cs = 결합된 2레벨 메모리의 비트당 평균 비용.

  • C1 =상위 메모리 M1의 비트당 평균 비용.

  • C2 = 하위 수준 메모리 M2의 비트당 평균 비용.

  • S1 = M1의 크기.

  • S2 = M2의 크기.

우리는 Cs\aboutC2를 원합니다. C1>>C2를 고려하면 S_1 &lt;&lt; S_2가 필요합니다. 아래 다이어그램은 이들의 관계를 보여줍니다.

이를 수행하려면 액세스 효율성이라고 하며 평균 액세스 시간(Ts)이 MI 액세스 시간(T1)에 얼마나 가까운지를 측정하는 T1/Ts 값을 고려하십시오.

\cfrac{T_1}{T_s} = \cfrac{1}{1+(1-H)\cfrac{T_2}{T_1}

아래 그림은 T1/Ts를 적중률 H의 함수로 나타내고 T1/Ts를 매개변수로 나타냅니다. 성능 요구 사항을 충족하려면 0.8~0.9 범위의 적중률이 필요한 것으로 보입니다.

적중률에 따른 액세스 효율성(r = T1/T2).

아래 그림은 지역성이 적중률에 미치는 영향을 보여줍니다. 분명히 MI가 M2와 동일한 크기라면 적중률은 1.0이 됩니다. M2의 모든 항목은 항상 저장됩니다. 이제 지역성이 없다고 가정합니다. 즉, 참조가 완전히 무작위이며 이 경우 적중률은 메모리 크기에 상대적인 선형 함수여야 합니다. 예를 들어, MI가 M2 크기의 절반이고 언제든지 M2 항목의 절반이 여기에 있으면 적중률은 0.5가 됩니다. 그러나 실제로 인용에는 어느 정도 지역성이 있습니다. 중간 정도의 지역성과 강한 지역성의 효과가 그림에 나와 있습니다.

상대 메모리 크기에 따른 적중률.

두 메모리의 상대적 크기가 비용 요구 사항을 충족합니까? 대답은 분명히 그렇습니다. 좋은 성능을 달성하기 위해 상대적으로 작은 상위 계층 메모리만 필요한 경우 두 계층의 비트당 평균 비용은 더 저렴한 하위 계층 메모리에 가깝습니다.

18.7.4 스레드 풀

Windows는 특정 스레드가 스레드 풀에서 작업을 보낼 수 있도록 하는 메커니즘인 스레드 풀을 제공합니다. 스레드를 수동으로 생성하고 관리하는 것보다 스레드 풀을 사용하면 다음과 같은 이점이 있습니다.

  • 클라이언트 코드에 의한 명시적인 스레드 생성 또는 종료가 없습니다. 스레드 풀 관리자가 이를 처리합니다.

  • 완료된 작업은 작업자 스레드를 삭제하지 않으며 다른 요청을 처리하기 위해 스레드 풀로 반환됩니다.

  • 풀의 스레드 수는 작업 항목 로드에 따라 동적으로 늘어나거나 줄어들 수 있습니다.

Windows 2000은 각 프로세스에 스레드 풀을 제공하는 스레드 풀의 첫 번째 버전을 제공했습니다. Windows Vista부터 전용 스레드 풀 추가를 포함하여 스레드 풀 API가 크게 향상되었습니다. 즉, 프로세스 내에 여러 스레드 풀이 존재할 수 있습니다. 관련 API:

cpp
PTP_WORK CreateThreadpoolWork(PTP_WORK_CALLBACK pfnwk, PVOID pv, PTP_CALLBACK_ENVIRON pcbe);
void CloseThreadpoolWork(PTP_WORK pwk);
VOID CloseThreadpoolWait(PTP_WAIT pwa);

VOID SubmitThreadpoolWork(PTP_WORK Work);
BOOL TrySubmitThreadpoolCallback(PTP_SIMPLE_CALLBACK pfns, PVOID pv, PTP_CALLBACK_ENVIRON pcbe);
void WaitForThreadpoolWorkCallbacks(PTP_WORK pwk, BOOL fCancelPendingCallbacks);
VOID SetThreadpoolWait(PTP_WAIT pwa, HANDLE h, PFILETIME pftTimeout);

18.7.5 사용자 모드 스케줄링

사용자 모드와 메모리 모드 간 전환에는 기본 소모량이 많기 때문에 둘 사이의 전환을 줄이면 성능이 향상될 수 있습니다.

초기 Windows 버전에서는 Fiber를 제공했지만 TLS가 올바르게 전달되지 않거나 스레드 환경 블록이 일치하지 않는 등 많은 문제가 있어 폐기되었습니다. Windows 7 및 Windows 2008 R2부터 Windows는 사용자 모드 스케줄링(UMS)이라는 대체 메커니즘을 지원합니다. 이 메커니즘에서는 사용자 모드 스레드가 사용자 모드/커널 모드 변환 없이 사용자 모드에서 스레드를 예약할 수 있는 일종의 스케줄러가 됩니다. 이 메커니즘은 커널에 알려져 있으므로 UMS는 스레드를 공유하는 파이버 대신 실제 스레드를 사용하기 때문에 파이버의 단점이 없습니다.

불행하게도 UMS를 사용하여 실제 시스템을 구축하는 것은 쉽지 않습니다. Microsoft(2010년부터)는 동시 실행이 필요할 때 스레드를 효율적으로 사용하기 위해 백그라운드에서 UMS를 사용하는 Concrt(약어로 "콘서트"라고 발음)라는 Concurrency Runtime이라는 라이브러리를 제공합니다.

18.7.6 한 번만 초기화

많은 애플리케이션의 일반적인 패턴은 싱글톤 개체를 사용하는 것입니다. 일부 관점에서는 이는 반패턴입니다. 실제로 싱글톤은 유용하고 때로는 필요합니다. 싱글톤에 대한 일반적인 요구 사항은 싱글톤을 한 번만 초기화하는 것입니다. 다중 스레드 애플리케이션에서 여러 스레드는 처음에 싱글톤 인스턴스에 동시에 액세스할 수 있지만 싱글톤 인스턴스는 한 번 초기화되어야 합니다. 이 목표를 달성하는 방법은 무엇입니까? 올바르게 구현되면 작업을 완료할 수 있는 몇 가지 잘 알려진 알고리즘이 있습니다. Windows API는 한 번만 호출되도록 보장되는 함수를 호출하는 기본 제공 방법을 제공합니다.

cpp
INIT_ONCE init = INIT_ONCE_STATIC_INIT;
VOID InitOnceInitialize(_Out_ PINIT_ONCE InitOnce);
BOOL InitOnceExecuteOnce(PINIT_ONCE InitOnce, __callback PINIT_ONCE_FN InitFn, PVOID Parameter, LPVOID* Context);

18.8 숙제

18.8.1 작업 개요

프로세스가 작업 아래에 있으면 작업 개체가 프로세스 탐색기에 간접적으로 표시됩니다. 이 경우 프로세스 속성에 작업 탭이 나타납니다. 프로세스가 작업 없음 상태이면 이 탭이 존재하지 않습니다. 작업을 수집하는 또 다른 방법은 옵션/색상 구성...에서 작업 색상(기본값은 갈색)을 활성화하는 것입니다. 아래 이미지는 작업 색상이 표시되고 다른 모든 색상은 제거된 프로세스 탐색기를 보여줍니다.

Windows 8에는 프로세스를 여러 작업과 연결하는 기능이 도입되어 작업을 제어하려는 프로세스가 이미 작업의 일부인 경우 다른 작업과 연결할 수 없기 때문에 작업을 이전보다 훨씬 더 유용하게 만들었습니다. 두 번째 작업이 할당되는 프로세스로 인해 작업 계층 구조가 생성되고(가능한 경우) 두 번째 작업이 첫 번째 작업의 하위 작업이 됩니다. 기본 규칙은 다음과 같습니다.

  • 상위 작업에 의해 부과된 제한은 작업과 모든 하위 작업(및 해당 작업 내의 모든 프로세스)에 영향을 미칩니다.

  • 상위 작업에 의해 부과된 제한은 하위 작업에 의해 제거될 수 없지만 더 제한적으로 만들 수 있습니다. 예를 들어 상위 작업이 작업 전체 메모리 제한을 200MB로 설정하면 하위 작업은 해당 프로세스 제한을 150MB로 설정할 수 있지만 250MB로는 설정할 수 없습니다.

다음 그림은 다음 작업을 순서대로 호출하여 생성된 작업의 계층 구조를 보여줍니다.

  1. 프로세스 P1을 작업 J1에 할당합니다.

  2. 프로세스 P1을 작업 J2에 할당하여 계층 구조를 형성합니다.

  3. 프로세스 P2를 작업 J2에 지정하십시오. 프로세스 P2는 이제 작업 J1 및 J2의 영향을 받습니다.

  4. 프로세스 P3을 작업 J1에 할당합니다.

작업 계층 구조를 보는 것은 쉽지 않습니다. 예를 들어 프로세스 탐색기는 작업 및 모든 하위 작업(있는 경우)을 보여주는 정보를 포함하여 작업 세부 정보를 표시합니다. 예를 들어, 위 그림에서 J1 작업의 정보를 보면 P1, P2, P3의 세 가지 프로세스가 나열됩니다. 또한 작업 액세스는 간접적이므로(프로세스가 작업 아래에 있는 경우 작업 탭을 사용할 수 있음) 표시되는 작업은 프로세스가 속한 직접 작업이며 상위 작업은 표시되지 않습니다. 다음 코드는 위 이미지에 표시된 계층 구조를 만듭니다.

cpp
#include <windows.h>
#include <stdio.h>
#include <assert.h>
#include <string>

HANDLE CreateSimpleProcess(PCWSTR name) 
{
    std::wstring sname(name);
    PROCESS_INFORMATION pi;
    STARTUPINFO si = { sizeof(si) };
    if (!::CreateProcess(nullptr, const_cast<PWSTR>(sname.data()), nullptr, nullptr, FALSE, CREATE_BREAKAWAY_FROM_JOB | CREATE_NEW_CONSOLE, nullptr, nullptr, &si, &pi))
    {
        return nullptr;
    }
    ::CloseHandle(pi.hThread);
    return pi.hProcess;
}

HANDLE CreateJobHierarchy() 
{
    auto hJob1 = ::CreateJobObject(nullptr, L"Job1");
    assert(hJob1);
    auto hProcess1 = CreateSimpleProcess(L"mspaint");
    auto success = ::AssignProcessToJobObject(hJob1, hProcess1);
    assert(success);
    auto hJob2 = ::CreateJobObject(nullptr, L"Job2");
    assert(hJob2);
    success = ::AssignProcessToJobObject(hJob2, hProcess1);
    assert(success);
    auto hProcess2 = CreateSimpleProcess(L"mstsc");
    success = ::AssignProcessToJobObject(hJob2, hProcess2);
    assert(success);
    auto hProcess3 = CreateSimpleProcess(L"cmd");
    success = ::AssignProcessToJobObject(hJob1, hProcess3);
    assert(success);
    // not bothering to close process and job 2 handles
    return hJob1;
}

int main()
{
    auto hJob = CreateJobHierarchy();
    printf("Press any key to terminate parent job...\n");
    ::getchar();
    ::TerminateJobObject(hJob, 0);
    return 0;
}

작업 제약 조건이 위반되거나 특정 이벤트가 발생하면 작업과 연결된 I/O 완료 포트를 통해 관련 당사자에게 알릴 수 있습니다. I/O 완료 포트는 일반적으로 비동기 I/O 작업 완료를 처리하는 데 사용되지만, 이 특별한 경우에는 특정 작업 이벤트의 발생을 알리는 메커니즘으로 사용됩니다.

작업은 CPU 시간 충돌이 발생할 때 신호를 보내는 스케줄러(대기 가능) 개체입니다. 이 간단한 경우 스레드는 WaitForSingleObject(일반적인 예)를 기다린 다음 CPU 시간 충돌을 처리할 수 있습니다. 새로운 CPU 시간 제한을 설정하면 작업이 신호 없음 상태로 재설정됩니다.

18.8.2 사일로

Windows 10 버전 1607 및 Windows Server 2016에는 Silos라는 향상된 작업 버전이 도입되었습니다. 사일로에는 두 가지 변형이 있습니다.

  • 애플리케이션 사일로. 데스크톱 브리징 기술을 사용하여 UWP로 변환된 애플리케이션의 경우 서버 Silos만큼 강력하지는 않습니다(그리고 그럴 필요도 없음). 작업 탐색기에는 해당 유형을 나열하여 작업이 실제로 사일로인지 여부를 나타내는 사일로 유형 열이 있습니다.

  • 서버 사일로. Windows Server 2016부터 시작하는 시스템에서만 지원되며 프로세스를 샌드박스화하고 프로세스가 자체 시스템에 있다고 생각하도록 가상 환경을 생성하는 기능인 Windows 컨테이너를 구현하는 데 사용됩니다. 파일 시스템, 레지스트리 및 개체 네임스페이스는 특정 사일로의 일부로 리디렉션되어야 하므로 커널은 사일로 메모리를 인식할 수 있도록 내부적으로 중요한 변경을 수행해야 합니다.

요약하면 작업은 커널 자체에서 구현되는 프로세스를 제어하고 제한할 수 있는 많은 기회를 제공합니다. Windows 8에 중첩된 작업이 도입되면서 작업이 더욱 유용해지고 제한이 줄어듭니다.

18.9 메모리

메모리 관리에는 논리적 및 물리적 주소 매핑, 메모리 할당, 내부 및 외부 조각화 및 압축, 페이징, 가상 메모리가 포함됩니다. 페이징 요청, 페이지 교체 전략. 이 장에서는 메모리 관련 개념, 기술, 원리 및 메커니즘에 대해 자세히 설명합니다.

18.9.1 메모리 기본 사항

오늘날의 최신 Intel/AMD 프로세서는 메모리 측면에서 매우 제한적으로 시작되었습니다. 원래 8086/8088 프로세서는 1MB의 메모리만 지원했습니다(당시에는 다른 메모리가 없었기 때문에 물리적 메모리). 메모리에 대한 각 액세스는 프로세서 내부적으로 16비트 값을 사용하는 세그먼트 주소와 오프셋의 조합이었지만 메모리 액세스에는 20비트(1MB)가 필요했습니다. 세그먼트 레지스터(16비트)의 값에 16(0x10)을 곱한 후 오프셋을 더해 1MB 범위의 주소에 도달한다. 이 작동 모드는 이제 리얼 모드라고 불리며 여전히 오늘날의 Intel/AMD 프로세서의 깨우기 모드입니다.

80386 프로세서의 도입으로 가상 메모리는 본질적으로 오늘날 사용되는 것과 마찬가지로 탄생했으며, 오프셋(세그먼트 레지스터는 0으로 설정됨)만을 사용하여 메모리에 선형적으로 액세스하는 기능을 포함하여 메모리 액세스가 더욱 편리해졌습니다. 가상 메모리는 각 메모리 액세스가 물리적 주소 위치로 변환되어야 함을 의미합니다. 이 모드를 보호 모드라고 합니다. 보호 모드에서는 물리적 메모리에 직접 액세스할 수 없으며 가상 주소에서 물리적 주소로의 매핑을 통해서만 액세스할 수 있습니다. 이 매핑은 CPU에 이 매핑이 필요하므로 운영 체제의 메모리 관리자가 미리 준비해야 합니다.

64비트 시스템에서는 보호 모드를 Long 모드라고 부르지만 본질적으로 동일한 메커니즘으로 64비트 주소로 확장됩니다.

가상 주소와 물리적 주소 간의 매핑과 운영 체제 수준의 메모리 블록 관리는 페이지라는 블록에서 수행됩니다. 모든 바이트를 관리하는 것은 불가능하기 때문에 페이지가 필요합니다. 관리되는 구조는 해당 바이트보다 훨씬 큽니다. 다음 표에는 Windows에서 지원하는 모든 아키텍처의 페이지 크기가 나열되어 있습니다.

건축 작은(보통) 페이지 큰 페이지 초대형 페이지

x86 4KB 2MB 해당 없음

x64 4KB 2MB 1GB

팔 4KB 2MB 2MB

ARM64 4KB 2MB 2MB

작은(일반) 페이지는 기본 페이지이며, 페이지는 일반적으로 모든 아키텍처에서 4KB인 작은 페이지 또는 일반 페이지를 의미합니다. 다양한 페이지 크기가 언급되는 경우 일반적으로 크거나 크다는 접두사가 붙습니다. 페이지 크기와 페이지 프레임 번호가 다른 경우 해당 페이지 부재 비율은 다음과 같습니다.

18.9.2 메모리 메커니즘

메모리 관리는 각각 고유한 주소를 가진 바이트 또는 단어 배열로 구성된 주 메모리 관리와 관련됩니다. CPU는 프로그램 카운터의 값을 기반으로 메모리에서 명령어를 가져오며, 이러한 명령어로 인해 특정 메모리 주소에서 추가 로드 및 저장이 발생할 수 있습니다. 메모리 장치는 메모리 주소의 스트림만 볼 수 있으며 어떻게 생성되었는지는 알 수 없습니다. 프로그램을 실행하려면 메모리에 가져와서 프로세스에 배치해야 합니다. 입력 큐는 실행을 위해 메모리에 들어가기를 기다리는 디스크의 프로세스 모음입니다. 사용자 프로그램은 실행되기 전에 여러 단계를 거칩니다.

메모리 관리 기능: 각 메모리 위치의 상태를 추적하고 할당 전략(메모리 할당 기술 및 할당 해제 기술)을 결정합니다.

메모리 관리: 논리적 및 물리적 주소 매핑, 메모리 할당, 내부 및 외부 조각화 및 압축, 페이징 가상 메모리: 페이징 요청, 페이지 교체 전략.

18.9.2.1 주소 바인딩

프로그램은 바이너리 실행 파일로 보조 스토리지 디스크에 저장됩니다. 프로그램이 실행될 때, 프로그램은 주 메모리에 저장되고 프로세스에 배치됩니다. 주 메모리에 들어가기를 기다리는 디스크의 프로세스 모음이 입력 대기열을 형성합니다. 실행될 프로세스 중 하나가 대기열에서 꺼내어 주 메모리에 배치됩니다. 실행 중에는 메인 메모리에서 명령어와 데이터를 가져오고, 프로세스가 종료되면 메모리 공간으로 돌아옵니다. 실행하는 동안 프로세스는 여러 단계를 거치며 각 단계에서 주소가 다른 방식으로 표시됩니다. 소스 프로그램에서 주소는 기호이고, 컴파일러는 기호 주소를 재배치 가능한 주소로 변환하고, 로더는 이 재배치 가능한 주소를 절대 주소로 변환합니다.

명령과 데이터의 바인딩은 프로세스의 어느 단계에서든 수행할 수 있습니다.

  • 컴파일 시간: 프로세스가 메모리에 상주하는지 여부를 알면 절대 코드를 생성할 수 있습니다. 정적 주소가 변경되면 코드를 처음부터 다시 컴파일해야 합니다.

  • 로드 시간: 컴파일러가 프로세스가 메모리에 상주하는지 여부를 알지 못하는 경우 재배치 가능한 코드를 생성합니다. 이 경우 로드 시간까지 바인딩이 지연됩니다.

  • 실행 시간: 프로세스가 실행 중에 한 메모리 세그먼트에서 다른 메모리 세그먼트로 이동하면 바인딩은 런타임까지 지연됩니다. 이를 위해서는 특별한 하드웨어가 필요하며, 대부분의 범용 운영체제에서는 이 방법을 사용합니다.

18.9.2.2 논리적 주소와 물리적 주소

CPU가 생성한 주소를 논리 주소 또는 가상 주소라고 합니다. 메모리 유닛이 보는 주소, 즉 메모리 레지스터에 로드된 주소를 물리적 주소라고 합니다. 컴파일 시간 및 로드 시간 주소 바인딩 방법은 일부 논리 주소와 물리적 주소를 생성하고, 실행 시간 주소 바인딩은 서로 다른 논리 주소와 물리적 주소를 생성합니다.

프로그램에 의해 생성된 논리 주소 공간의 집합은 논리 주소 공간이고, 이러한 논리 주소에 대응되는 물리 주소의 집합은 물리 주소 공간입니다. 런타임 시 가상 주소를 실제 주소로 매핑하는 작업은 MMU(메모리 관리 장치)라는 하드웨어 장치에 의해 수행됩니다.

기본 주소 레지스터 재배치 레지스터라고도 알려진 재배치 레지스터의 값은 메모리로 전송할 때 사용자 프로세스에 의해 생성된 모든 주소에 추가됩니다. 사용자 프로그램은 실제 물리적 주소를 볼 수 없습니다. 다음 다이어그램은 동적 관계를 보여줍니다. 동적 관계는 가상 주소 공간에서 물리적 주소 공간으로의 매핑을 의미하며 일반적으로 일부 하드웨어 지원을 통해 런타임에 수행됩니다.

재배치 레지스터를 사용한 동적 재배치 위 다이어그램은 동적 재배치를 보여줍니다. 이는 가상 주소 공간에서 물리적 주소 공간으로의 매핑을 의미하며 런타임 시 하드웨어에 의해 수행됩니다. 재배치는 하드웨어에 의해 수행되며 동적 재배치는 사용자에게 보이지 않으므로 부분적으로 실행된 프로세스는 영향 없이 메모리의 한 영역에서 다른 영역으로 이동할 수 있습니다. 구체적인 예:

  • 베이스가 14000인 경우 위치 0을 주소 지정하려는 사용자 시도는 위치 14000으로 동적으로 재배치되고 위치 346에 대한 액세스는 위치 14346에 매핑됩니다.

  • 사용자 프로그램은 실제 물리적 주소를 볼 수 없습니다.

  • 프로그램은 위치 346에 대한 포인터를 생성하고, 이를 메모리에 저장하고, 이를 연산하고, 이를 다른 주소(모두 346)와 비교할 수 있습니다.

  • 메모리 주소로 사용되는 경우에만(간접 로드 또는 저장에서) 기본 레지스터를 기준으로 재배치됩니다.

  • 사용자 프로그램은 논리 주소를 처리합니다.

  • 메모리 매핑 하드웨어는 논리 주소를 물리 주소로 변환합니다.

  • 이제 두 가지 다른 유형의 주소가 있습니다. 즉, 논리 주소(0에서 최대 범위)와 물리적 주소(기본 R의 경우 R+0에서 R+최대 범위)입니다.

  • 사용자 프로그램은 논리 주소만 생성하고 프로세스가 0에서 최대 위치까지 실행되는 것으로 간주합니다. 그러나 이러한 논리 주소를 사용하려면 먼저 물리 주소에 매핑되어야 합니다.

  • 별도의 물리적 주소 공간에 바인딩된 논리적 주소 공간의 개념은 적절한 메모리 관리의 핵심입니다.

동적 로딩: 프로세스가 실행되려면 물리적 메모리에 로딩되어야 합니다. 프로세스 크기는 실제 메모리 크기로 제한됩니다. 동적 로딩은 더 나은 메모리 활용도를 얻기 위해 사용됩니다. 동적 로딩에서는 루틴이나 프로시저가 호출되지 않으면 로드되지 않습니다. 루틴이 호출될 때마다 호출 루틴은 먼저 호출된 루틴이 로드되었는지 확인합니다. 로드되지 않은 경우 로더는 필요한 프로그램을 메모리에 로드하고 프로그램 주소 테이블을 업데이트하여 새로 호출된 루틴에 전달될 변경 사항 및 제어를 나타냅니다.

장점: 더 나은 메모리 활용도를 제공하고, 사용하지 않는 루틴을 로드하지 않으며, 특별한 운영 체제 지원이 필요하지 않습니다. 이 방법은 자주 발생하는 상황에서 많은 양의 코드를 처리해야 하는 경우에 유용합니다.

스와핑은 시스템 메모리에서 비활성 프로그램을 일시적으로 제거하는 기술입니다. 프로세스는 일시적으로 메모리에서 백업 저장소로 교체된 다음 메모리로 반환되어 실행을 계속할 수 있습니다. 이 프로세스를 교환이라고 합니다. 예를 들어:

예:

  • 라운드 로빈 CPU 스케줄링을 사용하는 다중 프로그래밍 환경에서는 기간이 만료될 때마다 방금 완료된 프로세스가 교체되고 새 프로세스가 실행을 위해 메모리로 교체됩니다.

  • 교환의 변형은 우선순위 기반 스케줄링입니다. 낮은 우선순위가 실행되는 동안 높은 우선순위 프로세스가 도착하면 낮은 우선순위가 호출되고 높은 우선순위의 실행이 허용됩니다. 이 프로세스를 롤아웃 및 롤인이라고도 합니다.

  • 일반적으로 호출되는 프로세스는 주소 바인딩에 따라 이전에 점유했던 동일한 메모리 공간으로 다시 호출됩니다.

  • 로드 시 바인딩이 완료되면 프로세스가 동일한 메모리 위치로 이동됩니다. 바인딩이 런타임에 완료되면 프로세스는 다른 메모리 위치로 이동됩니다. 물리적 주소는 런타임에 계산되기 때문입니다.

  • 스왑에는 백업 저장소가 필요하며 모든 메모리 이미지의 복사본을 저장할 수 있을 만큼 충분히 커야 합니다. 시스템은 메모리 이미지가 백업 저장소나 메모리에 있고 실행 준비가 되어 있는 모든 프로세스로 구성된 준비 대기열을 유지 관리합니다.

스와핑 결정 요인: 프로세스를 스와핑하려면 완전히 유휴 상태여야 합니다. 프로세스가 I/O 작업을 기다리고 있을 수 있습니다. I/O가 I/O 버퍼의 사용자 메모리에 비동기적으로 액세스하는 경우 프로세스를 교체할 수 없습니다. 논리 주소와 물리 주소를 비교하면 다음과 같습니다.

논리 주소 실제 주소

CPU가 생성한 주소입니다. 저장 장치에 표시되는 주소입니다.

프로그램이 생성하는 모든 논리 주소의 집합이 논리 주소 공간이다. 이러한 논리 주소에 해당하는 모든 물리 주소의 집합이 물리 주소 공간이다.

논리 주소(범위는 0부터 최대까지). 물리적 주소(기본 값 R의 경우 R+0 ~ R+max 범위).

사용자는 프로그램의 논리 주소를 볼 수 있습니다. 사용자는 프로그램의 물리적 주소를 결코 볼 수 없습니다.

사용자는 논리적 주소를 사용하여 물리적 주소에 접근할 수 있습니다. 사용자는 물리적 주소에 간접적으로 액세스할 수 있지만 직접적으로 액세스할 수는 없습니다.

논리 주소는 가변적이므로 시스템마다 다릅니다. 객체의 물리적 주소는 항상 동일하게 유지됩니다.

지속적인 메모리 할당: 메모리 할당의 가장 간단한 방법 중 하나는 메모리를 여러 개의 고정 파티션으로 나누는 것입니다. 주 메모리는 일반적으로 두 개의 파티션으로 나뉩니다. 상주 운영 체제는 일반적으로 인터럽트 벡터와 함께 낮은 메모리에 저장됩니다. 그리고 높은 메모리에 저장되는 사용자 프로세스. 각 파티션에는 정확히 하나의 프로세스가 포함되며 다중 프로그래밍 정도는 파티션 수에 따라 달라집니다.

메모리 매핑 및 보호: 메모리 보호는 사용자 프로세스로부터 운영 체제를 보호하고 다른 프로세스로부터 프로세스를 보호하는 것을 의미합니다. 논리 주소 범위를 포함하는 제한 레지스터와 함께 최소 물리적 주소 값을 포함하는 재배치 레지스터를 사용하여 메모리 보호가 제공됩니다. (재배치=100040, 제한=74600)

논리 주소는 제한 레지스터보다 작아야 하며 MMU는 재배치 레지스터에 값을 추가하여 논리 주소를 동적으로 매핑합니다. CPU 스케줄러가 컨텍스트 전환의 일부로 실행할 프로세스를 선택하면 스케줄러는 올바른 값으로 재배치 및 제한 레지스터를 로드합니다. CPU에서 생성된 모든 주소는 이러한 레지스터와 비교하여 확인되므로 운영 체제와 다른 사용자의 프로그램 및 데이터가 수정되지 않도록 보호할 수 있습니다.

18.9.2.3 메모리 할당

다중 파티션 할당:

홀은 사용 가능한 메모리 블록이며 다양한 크기의 홀이 메모리 전체에 흩어져 있습니다. 프로세스가 도착하면 이를 수용할 수 있을 만큼 큰 홀에서 메모리가 할당됩니다. 운영 체제는 할당된 파티션과 사용 가능한 파티션(홀)에 대한 정보를 유지 관리합니다. 주어진 시간에 메모리 전체에 흩어져 있는 다양한 크기의 구멍 집합입니다.

프로세스가 도착하고 메모리가 필요할 때 시스템은 컬렉션을 검색하여 프로세스를 수용할 수 있을 만큼 큰 구멍을 찾습니다. 구멍이 너무 크면 두 부분으로 나누어집니다. 하나는 도착 프로세스에 할당되고 다른 하나는 구멍 그룹으로 반환됩니다.

프로세스가 종료되면 메모리 블록을 해제하고 이를 다시 구멍 세트에 넣습니다. 새 구멍이 다른 구멍과 인접해 있는 경우 이러한 인접한 구멍은 더 큰 구멍으로 병합됩니다. 이 프로세스는 일반적인 동적 저장 할당 문제, 즉 여유 구멍 목록에서 크기 n의 요청을 충족하는 방법의 특별한 사례입니다.

이 문제에 대한 많은 해결책이 있습니다. 가장 잘 할당된 홀을 결정하기 위해 홀 세트를 검색합니다. 첫 번째 매치, 가장 좋은 매치, 최악의 매치 전략은 사용 가능한 홀 세트에서 빈 홀을 선택하는 데 사용되는 가장 일반적인 전략입니다. 자유 구멍을 선택하는 세 가지 전략에 대한 설명:

  • 첫 번째 맞춤: 충분히 큰 첫 번째 구멍을 할당합니다. 알고리즘은 처음부터 메모리를 스캔하고 프로세스를 저장할 만큼 큰 사용 가능한 첫 번째 블록을 선택합니다.

  • 최적합: 요구사항에 가장 가까운 크기의 구멍을 선택하고 가장 작은 구멍, 즉 공정을 수용할 수 있을 만큼 충분히 큰 구멍을 할당합니다.

  • 최악의 적합: 프로세스 요청에 가장 큰 구멍을 할당하고 전체 목록에서 가장 큰 구멍을 검색합니다.

First-fit과 best-fit은 가장 널리 사용되는 동적 메모리 할당 알고리즘입니다. First-fit은 일반적으로 더 빠르며 Best-fit은 전체 목록을 검색하여 가장 작은 구멍, 즉 충분히 큰 구멍을 찾습니다. 최악의 일치는 가장 작은 구멍이 생성되는 속도를 줄입니다. 이러한 모든 알고리즘에는 조각화 문제가 있습니다.

18.9.2.4 메모리 조각화 및 조각 모음

프로세스가 로드되고 메모리에서 제거되면 여유 메모리 공간이 작은 덩어리로 나뉩니다. 일정 시간이 지나면 메모리에 작은 메모리 블록이 있고 메모리 블록이 여전히 사용되지 않기 때문에 메모리에서 제거한 프로세스를 할당할 수 없습니다. 이 문제를 단편화라고 합니다.

또는 시스템에서 메모리가 순차적으로/연속적으로 할당되고, 서로 다른 파일이 블록 형태로 메모리에 저장되고, 이러한 파일이 자주 수정되거나 삭제되면 그 사이에 공백/사용 가능한 공간이 남게 되는 경우를 조각화라고 합니다.

메모리 조각화에는 두 가지 유형이 있습니다.

  • 내부 잔해. 로드된 데이터 블록이 파티션보다 작기 때문에 섹션 내부에 낭비되는 공간이 있습니다. 이 경우 사용 가능한 공간이 너무 작아 파일을 수용할 수 없습니다. 여유 공간을 추적하는 오버헤드는 운영 체제에 비해 너무 크며, 이를 내부 조각화라고 합니다. 예: 50kb 블록이 있고 프로세스가 40kb를 요청한 경우 해당 블록을 프로세스에 할당하면 10kb의 메모리가 남습니다.

  • 외부 잔해. 요청을 만족시키기에 충분한 메모리 공간이 있을 때 존재하지만 연속적이지 않습니다. 즉, 저장소가 다수의 작은 구멍으로 분할됩니다. 외부 잔해는 사소한 문제일 수도 있고 큰 문제일 수도 있습니다. 그림과 같이 해제된 공간의 합만큼 파일을 메모리에 넣으려고 해도 공간이 연속되어 있지 않기 때문에 불가능합니다. 따라서 파일을 저장할 수 있는 메모리는 충분하지만 메모리가 여러 위치에 분산되어 있으므로 이를 수용할 수 없습니다. 이것이 바로 외부 단편화입니다.

외부 단편화를 극복하는 한 가지 솔루션은 압축입니다. 목표는 모든 사용 가능한 메모리를 하나의 큰 블록으로 함께 이동하는 것입니다. 압축이 항상 가능한 것은 아니며 위치 변경이 정적이고 로드하는 동안 수행되는 경우에는 불가능합니다. 재배치가 동적이고 실행 시 완료되면 압축이 가능합니다.

외부 단편화 문제에 대한 또 다른 가능한 해결책은 프로세스의 논리 주소 공간을 비연속적으로 허용하여 실제 메모리를 사용할 수 있게 되면 프로세스에 실제 메모리를 할당하는 것입니다.

고정 분할 방식과 동적 분할 방식 모두 단점이 있습니다. 고정 파티셔닝 방식은 활성 프로세스 수를 제한하고 사용 가능한 파티션 크기와 프로세스 크기가 일치하지 않는 경우 공간을 비효율적으로 사용할 수 있습니다. 동적 파티셔닝 방식은 압축 오버헤드를 유지하고 포함하기가 더 복잡합니다. 흥미로운 절충안은 Buddy System입니다.

요청된 할당의 크기가 k인 경우(예: 2i1<k2i), 다음 재귀 알고리즘을 사용하여 2i 크기의 구멍을 찾습니다.

cpp
void get_hole(int i) 
{
    if(i==(U+1)) <failure>;
    if(<i_list_empty>)
    {
        get_hole(i+1);
        <split hole into buddies>;
        <put buddies on i_list>;
    }
    <take first hole on i_list>;
}

아래 그림은 1M byte 초기 블록을 사용한 예를 보여줍니다. 첫 번째 요청 A는 100K 바이트에 대한 것이며 128K 블록이 필요합니다. 초기 블록은 두 개의 512K 친구로 분할되고, 첫 번째 블록은 두 개의 256K 친구로 분할되고, 첫 번째 요청은 두 개의 128K 친구로 분할되고, 그 중 하나는 A에 할당됩니다. 다음 요청 B에는 256K 블록이 필요하며, 이러한 블록은 이미 사용 가능하고 할당되어 있습니다. 필요에 따라 분할하고 병합하는 과정이 계속됩니다. E가 출시되면 참고하세요. 두 명의 128K 친구가 256K 블록으로 병합되어 즉시 파트너와 병합됩니다.

아래 그림은 B 요청이 해제된 직후 친구 할당의 이진 트리 표현을 보여 주며, 리프 노드는 현재 메모리 파티션을 나타냅니다. 두 파트너가 리프 노드인 경우 최소한 하나를 할당해야 하며, 그렇지 않으면 더 큰 블록으로 병합됩니다.

버디 시스템의 트리 표현.

프리 프레임에 프로세스를 할당합니다.

18.9.2.5 페이징

대부분의 가상 메모리 시스템은 페이징이라는 기술을 사용합니다. 모든 컴퓨터에서 프로그램은 다음과 같은 명령을 실행할 때 일련의 메모리 주소를 참조합니다.

cpp
MOV REG, 1000

이것이 수행하는 작업은 메모리 주소 1000의 내용을 REG에 복사하는 것입니다(또는 컴퓨터에 따라 그 반대). 주소는 인덱스, 기본 레지스터, 세그먼트 레지스터 및 기타 수단을 사용하여 생성될 수 있습니다.

MMU의 위치와 기능. 여기에 표시된 MMU는 요즘 흔히 볼 수 있는 CPU 칩의 일부입니다. 그러나 논리적으로는 별도의 칩일 수도 있었으며 이는 몇 년 전의 일이었습니다.

MMU의 내부 작동.

하드웨어는 재할당을 지원합니다.

이러한 프로그램에 의해 생성된 주소를 가상 주소라고 하며 가상 주소 공간을 구성합니다. 가상 메모리가 없는 컴퓨터에서는 가상 주소가 메모리 버스에 직접 배치되어 동일한 주소를 가진 물리적 메모리 단어를 읽거나 쓸 수 있습니다. 가상 메모리를 사용할 때 가상 주소는 메모리 버스에 직접 도달하지 않습니다. 대신 위 그림과 같이 가상 주소를 물리적 메모리 주소로 매핑하는 MMU(Memory Management Unit)로 들어갑니다.

아래 이미지는 이 매핑이 작동하는 방식에 대한 매우 간단한 예를 보여줍니다. 이 예에서는 0부터 64K-1까지의 16비트 주소를 생성할 수 있는 컴퓨터가 있는데, 이는 가상 주소입니다. 그러나 이 컴퓨터에는 실제 메모리가 32KB밖에 없습니다. 따라서 64KB 프로그램을 작성할 수는 있지만 모두 메모리에 로드하여 실행할 수는 없습니다. 그러나 필요에 따라 개별 부분을 넣을 수 있도록 프로그램 핵심 이미지의 전체 복사본(최대 64KB)이 디스크에 있어야 합니다.

가상 주소 공간은 페이지라고 불리는 고정된 크기의 단위로 구성됩니다. 물리적 메모리의 해당 단위를 페이지 프레임이라고 합니다. 페이지와 페이지 프레임은 일반적으로 크기가 동일합니다. 이 예에서는 4KB이지만 실제 시스템에서 사용되는 페이지 크기는 512바이트에서 1GB까지 다양합니다. 64KB의 가상 주소 공간과 32KB의 물리적 메모리를 사용하면 16개의 가상 페이지와 8개의 페이지 프레임을 얻을 수 있습니다. RAM과 디스크 간의 전송은 항상 전체 페이지이며 많은 프로세서는 운영 체제의 필요에 따라 혼합 및 일치할 수 있는 여러 페이지 크기를 지원합니다. 예를 들어 x86-64 아키텍처는 4KB, 2MB 및 1GB 페이지를 지원하므로 사용자 애플리케이션에는 4KB 페이지를 사용하고 커널에는 1GB 단일 페이지를 사용할 수 있습니다.

가상 주소와 물리 메모리 주소의 관계는 페이지 테이블을 통해 알 수 있습니다. 각 페이지는 4096의 배수로 시작하여 4095의 주소로 끝나므로 4K~8K는 실제로 4096~8191을 의미하고 8K~12K는 8192~12287을 의미합니다.

페이징은 프로세스의 물리적 주소 공간이 불연속적일 수 있도록 하는 메모리 관리 체계입니다. 페이징 지원은 하드웨어에 의해 처리되며 외부 조각화를 방지하는 데 사용됩니다. 페이징은 다양한 크기의 메모리 블록을 백업 저장소에 맞추는 상당한 문제를 방지합니다. 메인 메모리의 일부 코드나 날짜를 교환해야 할 경우 백업 메모리에 공간을 찾아야 합니다. 물리적 메모리는 프레임(f)이라는 고정된 크기의 덩어리로 나뉩니다. 논리적 메모리는 페이지(p)라는 동일한 크기의 블록으로 나뉩니다. 프로세스가 실행될 때 해당 페이지는 백업 저장소에서 사용 가능한 프레임으로 로드됩니다. 블록 스토리지는 메모리 프레임과 동일한 크기의 고정 크기 블록으로 분할되기도 합니다.

예를 들어 운영 체제가 페이지 1 프레임을 폐기하기로 결정하면 물리적 주소 4096에 가상 페이지 8을 로드하고 MMU 매핑에 두 가지 변경 사항을 적용합니다. 먼저 가상 페이지 1에 대한 항목을 매핑되지 않은 것으로 표시하여 4096과 8191 사이의 가상 주소에 대한 향후 액세스를 포착합니다. 그런 다음 가상 페이지 8 항목의 포크를 1로 대체하여 캡처된 명령어가 다시 실행될 때 가상 주소 32780을 물리적 주소 4108(4096+12)에 매핑합니다.

이제 MMU 내부를 살펴보고 그것이 어떻게 작동하는지, 왜 우리가 2 페이지 크기를 사용하기로 선택했는지 살펴보겠습니다. 아래 이미지에서는 MMU 매핑을 사용하여 매핑된 가상 주소 8196(바이너리 00100000000000100)의 예를 볼 수 있습니다. 들어오는 16비트 가상 주소는 4비트 페이지 번호와 12비트 오프셋으로 분할됩니다. 페이지 번호에 4비트를 사용하면 16페이지를 가질 수 있고, 오프셋에 12비트를 사용하면 페이지의 4096바이트를 모두 주소 지정할 수 있습니다.

16개의 4KB 페이지에 대한 MMU 내부 작업.

일반적인 페이지 테이블 항목입니다.

페이지 번호는 해당 가상 페이지에 해당하는 페이지 프레임 번호를 생성하기 위해 페이지 테이블에 대한 인덱스로 사용됩니다. 현재/누락된 비트가 0이면 운영 체제 트랩이 발생합니다. 비트가 1이면 페이지 테이블에서 찾은 페이지 프레임 번호가 들어오는 가상 주소에서 변경되지 않고 복사된 12비트 오프셋과 함께 출력 레지스터의 상위 3비트에 복사됩니다. 이들은 함께 15비트 물리적 주소를 형성합니다. 그런 다음 출력 레지스터는 메모리 버스에 물리적 메모리 주소로 배치됩니다.

2단계 계층 페이지 테이블.

2단계 페이징 시스템에 대한 주소 변환.

CPU가 생성한 논리 주소는 페이지 번호(p)와 페이지 오프셋(d)의 두 부분으로 나뉩니다. 페이지 번호(p)는 물리적 메모리에 있는 각 페이지의 기본 주소를 포함하는 페이지 테이블에 대한 인덱스로 사용됩니다. 이 기본 주소는 페이지 오프셋과 결합되어 물리적 메모리를 정의합니다. 즉, 메모리 장치로 전송됩니다. 다음 그림은 페이징 하드웨어를 보여줍니다.

페이지 크기는 하드웨어에 따라 정의됩니다. 크기는 2의 거듭제곱이며 페이지당 512바이트에서 10Mb 사이입니다. 논리 주소 공간의 크기가 2m 주소 단위이고 페이지 크기가 2n인 경우 상위 비트 m-n은 페이지 번호를 나타내고 하위 비트 n은 페이지 오프셋을 나타냅니다.

역방향 페이지 테이블 구조.

예: 아래 다이어그램과 함께 논리적 메모리가 물리적 메모리에 매핑되는 방식을 보여주기 위해 페이지 크기가 4바이트이고 물리적 메모리가 32바이트(8페이지)라고 가정합니다.

  • 논리 주소 0은 페이지 0, 오프셋은 0, 페이지 0은 프레임 5에 있습니다. 따라서 논리 주소 0과 물리 주소의 매핑은 [(54)+0]=20입니다.

  • 논리 주소 3은 페이지 0이고, 오프셋 3은 물리 주소 [(54)+3]=23에 매핑됩니다.

  • 논리 주소 4는 페이지 1이고 오프셋은 0이며 페이지 1은 프레임 6에 매핑됩니다. 따라서 논리 주소 4와 물리 주소의 매핑은 [(64)+0]=24입니다.

  • 논리 주소 13은 페이지 3이고, 오프셋 1과 페이지 3은 프레임 2에 매핑됩니다. 따라서 논리 주소 13과 물리 주소의 매핑은 [(24)+1]=9입니다.

변환 예측 버퍼 사용.

프로세스가 실행을 위해 시스템에 도달하면 크기(페이지 단위)가 확인됩니다. 프로세스의 각 페이지에는 프레임이 필요합니다. 따라서 프로세스에 n 페이지가 필요한 경우 메모리에 사용 가능한 프레임이 n개 이상 있어야 합니다. n개의 프레임을 사용할 수 있으면 이 도착 프로세스에 할당됩니다. 프로세스의 첫 번째 페이지는 할당된 프레임에 로드되고 프레임 번호는 프로세스의 페이지 테이블에 저장됩니다. 다음 페이지는 다른 프레임에 로드되고 해당 프레임 번호는 페이지 테이블에 입력됩니다.

운영 체제는 물리적 메모리를 관리하므로 물리적 메모리 할당 세부 사항, 즉 할당된 프레임, 사용 가능한 프레임, 총 프레임 수 등을 알아야 합니다. 이 정보는 일반적으로 프레임 테이블이라는 데이터 구조에 보관됩니다. 프레임 테이블에는 각 물리적 페이지 프레임에 대한 항목이 있으며, 이는 후자가 사용 가능한지 할당되었는지, 할당된 경우 프로세스 또는 프로세스의 페이지를 나타냅니다.

페이징 교체 알고리즘은 다음과 같습니다.

  • 최적의 페이지 교체 알고리즘

  • 최근에 사용되지 않은 페이지 교체 알고리즘

  • 선입선출 페이지 교체 알고리즘

  • 두 번째 기회 페이지 교체 알고리즘

  • 시계 페이지 교체 알고리즘

  • 가장 최근에 사용된 페이지 교체 알고리즘(LRU)

  • 소프트웨어에서 LRU 시뮬레이션(소프트웨어에서 LRU 시뮬레이션)

  • Working Set 페이지 교체 알고리즘

  • WSClock 페이지 교체 알고리즘

페이지 교체 알고리즘은 다음 표에 요약되어 있습니다.

알고리즘 설명

최고 구현 불가능하지만 기준으로 사용할 수 있음

NRU(최근 사용되지 않음) LRU의 대략적인 근사치

FIFO(선입선출) 중요한 페이지가 던져질 수 있습니다

두 번째 기회 FIFO에 비해 큰 개선

시계 실시간

LRU(가장 최근에 사용됨) 매우 훌륭하지만 정확하게 구현하기가 어렵습니다.

NFU(일반적으로 사용되지 않음) LRU와 매우 가깝습니다.

노화 LRU에 가까운 효율성

작업 환경 구현 비용이 다소 높음

WSClock 효율적인 알고리즘

최고의 알고리즘은 미래에 가장 많이 참조될 페이지를 제거합니다. 안타깝게도 이것이 어느 페이지인지 확인할 수 있는 방법이 없으므로 이 알고리즘은 실제로 사용할 수 없습니다. 그러나 이는 다른 알고리즘을 측정할 수 있는 벤치마크 역할을 할 수 있습니다.

NRU 알고리즘은 R 및 M 비트의 상태에 따라 페이지를 네 가지 범주로 분류합니다. 페이지는 가장 낮은 번호의 클래스에서 무작위로 선택됩니다. 이 알고리즘은 구현하기 쉽지만 조잡하고 더 나은 알고리즘이 존재합니다.

FIFO는 페이지를 연결된 목록에 저장하여 페이지가 메모리에 로드되는 순서를 추적합니다. 그러면 가장 오래된 페이지를 삭제하는 것이 간단해지지만 해당 페이지가 여전히 사용 중일 수 있으므로 FIFO는 잘못된 선택입니다.

두 번째 기회는 FIFO를 수정하고 페이지를 삭제하기 전에 페이지가 사용 중인지 확인하는 것입니다. 그렇다면 페이지는 무료이며 이 수정으로 성능이 크게 향상됩니다. 시계는 동일한 성능 속성을 갖지만 알고리즘을 실행하는 데 약간 더 적은 시간이 걸리는 두 번째 기회의 다른 구현일 뿐입니다.

LRU는 좋은 알고리즘이지만 특별한 하드웨어 없이는 구현할 수 없습니다. 이 하드웨어를 사용할 수 없으면 사용할 수 없습니다. NFU는 LRU를 근사화하려는 조잡한 시도이며 그다지 좋지 않습니다. 그러나 에이징은 LRU에 더 가깝고 효율적으로 구현될 수 있으며 좋은 대안입니다.

마지막 두 알고리즘은 작업 세트를 사용합니다. 작업 세트 알고리즘은 합리적인 성능을 제공하지만 구현 비용이 다소 비쌉니다. WSClock은 우수한 성능을 제공할 뿐만 아니라 구현에도 매우 효율적인 변형입니다.

요약하면 두 가지 최고의 알고리즘은 에이징과 WSClock입니다. 이들은 각각 LRU와 작업 세트를 기반으로 하며 둘 다 우수한 페이징 성능을 가지며 효율적으로 구현할 수 있습니다. 다른 좋은 알고리즘도 있지만 실제로는 이 두 가지가 가장 중요할 것입니다.

18.9.2.6 주소 변환

페이지 주소는 논리 주소라고 하며 페이지 번호와 오프셋으로 표시됩니다.

cpp
Logical Address = Page number + page offset

프레임 주소는 물리적 주소라고 하며 프레임 번호와 오프셋으로 표시됩니다.

cpp
Physical Address = Frame number + page offset

페이지 맵이라는 데이터 구조는 프로세스의 페이지와 실제 메모리의 프레임 간의 관계를 추적하는 데 사용됩니다.

시스템이 페이지에 프레임을 할당하면 해당 논리 주소를 실제 주소로 변환하고 프로그램 실행 전반에 걸쳐 사용할 수 있도록 페이지 테이블에 항목을 생성합니다.

프로세스가 실행되면 해당 페이지가 사용 가능한 메모리 프레임에 로드됩니다. 8Kb 프로그램이 있지만 특정 시점에 메모리가 5Kb만 보유할 수 있다고 가정하면 페이징 개념이 작동하게 됩니다. 컴퓨터의 RAM이 부족해지면 운영 체제(OS)는 사용 가능하거나 불필요한 메모리 페이지를 보조 메모리로 이동하여 다른 프로세스를 위해 RAM을 확보하고 프로그램에 필요할 때 이를 복원합니다.

프로그램이 실행되는 동안 운영 체제는 지속적으로 주 메모리에서 사용 가능한 페이지를 제거하고 보조 메모리에 쓴 다음 프로그램에 필요할 때 복원합니다.

페이지 매김의 장점과 단점:

  • 페이징은 외부 조각화를 줄일 수 있지만 여전히 내부 조각화의 영향을 받습니다.

  • 페이징은 구현이 간단하고 효율적인 메모리 관리 기술로 평가됩니다.

  • 페이지와 프레임의 크기가 동일하므로 교환이 매우 쉽습니다.

  • 페이지 테이블에는 추가 메모리 공간이 필요하므로 RAM이 작은 시스템에는 적합하지 않을 수 있습니다.

18.9.3 프로세스 주소 공간

각 프로세스에는 운영 체제 비트(32 또는 64) 및 프로세스 비트를 기반으로 주소 0에서 시작하여 최대값으로 끝나는 자체 선형, 가상 및 개인 주소 공간이 있습니다. 아래 그림은 개인 메모리 주소 공간에서 서로 다른 프로세스가 가리키는 실제 물리적 주소를 보여줍니다. 물리적 주소에는 RAM과 디스크가 포함됩니다.

프로세스는 자신의 주소 공간에 있는 메모리에 직접 액세스할 수 있습니다. 즉, 한 프로세스가 단순히 포인터를 조작하는 것만으로는 실수로 또는 악의적으로 다른 프로세스의 주소 공간을 읽거나 쓸 수 없습니다. 다른 프로세스의 메모리에 액세스하는 것이 가능하지만 이를 위해서는 대상 프로세스에 대해 충분히 강력한 핸들이 있는 함수(ReadProcessMemory 또는 WriteProcessMemority)를 호출해야 합니다.

프로세스의 주소 공간을 가상이라고 하며, 이는 주소 공간이 잠재적으로 메모리 매핑된 공간일 뿐이라는 사실을 나타냅니다. 각 프로세스는 가상 주소 공간을 아주 적게 사용하는 것으로 시작됩니다. 실행 파일은 ntdll.Dll과 매핑됩니다. 그런 다음 로더(NtDll의 일부)는 프로세스 주소 공간 내에 기본 프로세스 힙, 프로세스 환경 블록(PEB) 및 프로세스의 첫 번째 스레드의 스레드 환경 블록(TEB)과 같은 일부 기본 구조를 할당합니다. 대부분의 주소 공간은 비어 있습니다.

프로세스의 요구 사항을 해결합니다.

18.9.3.1 페이지 상태

가상 메모리의 각 페이지는 사용 가능, 커밋, 예약의 세 가지 상태 중 하나일 수 있습니다.

  • 무료 페이지: 매핑되지 않으므로 페이지에 액세스하려고 하면 액세스 위반 예외가 발생합니다. 대부분의 프로세스 주소 공간은 무료로 시작됩니다.

  • 커밋 페이지: 무료 페이지의 반대 - RAM 또는 파일에 매핑된 매핑된 페이지로, 액세스가 성공해야 합니다. 페이지가 RAM에 있으면 CPU는 데이터에 직접 액세스하여 계속 진행합니다. 페이지가 RAM(적어도 CPU 쿼리가 알려주는 테이블)에 없으면 CPU는 페이지 오류라는 예외를 발생시키고 메모리 관리자가 이를 포착합니다. 페이지가 디스크에 있으면 메모리 관리자는 이를 RAM으로 다시 가져오고 RAM의 새 주소를 가리키도록 변환 테이블을 복구한 다음 CPU에 다시 시도하도록 지시합니다. 호출 스레드의 관점에서 볼 때 최종 결과는 액세스가 성공한다는 것입니다. I/O가 실제로 포함되면 액세스 속도가 느려지지만 호출 스레드는 이를 알거나 이에 대해 특별한 작업을 수행할 필요가 없습니다. 이는 투명합니다.

메모리 커밋을 메모리 할당이라고도 합니다. malloc, calloc, Operator new 등과 같은 C/C++ 메모리 할당 함수를 호출할 때 메모리는 항상 커밋됩니다.

  • 예약된 페이지: 유휴 상태와 커밋 사이에 이 페이지에 액세스하면 액세스 위반이 발생한다는 점에서 유휴 페이지와 유사합니다. 거기에는 아무것도 없습니다. 예약된 페이지는 나중에 커밋될 수 있으며 예약된 페이지 범위는 스레드 스택 관리와 같은 다른 목적으로 예약되어 있으므로 일반 메모리 할당에서 해당 범위를 사용하지 않도록 합니다. 스레드의 스택은 커질 수 있고 가상 메모리에서 연속되어야 하기 때문에 프로세스에서 발생하는 다른 할당이 예약된 주소 범위를 사용하지 않도록 페이지 범위가 예약됩니다.

다음 표에는 페이지의 세 가지 상태와 특성이 요약되어 있습니다.

페이지 상태 의미 방문하는 경우

유휴(무료) 할당되지 않은 페이지 접근 위반 예외

커밋됨 할당된 페이지 성공(페이지 보호 제한이 없다고 가정)

예약됨 할당되지 않은 페이지, 향후 사용을 위해 예약됨 접근 위반 예외

18.9.3.2 주소 공간 레이아웃

다음 표에는 다양한 시스템의 주소 공간 크기가 요약되어 있습니다.

운영체제 유형 프로세스 유형 LARGEADDRESSAWARE 정리 LARGEADDRESSAWARE 설정

32비트 시작, UVA가 추가되지 않음 32비트 2GB 2GB

32비트 부팅, UVA 추가 32비트 2GB 2GB ~ 3GB

64비트(Windows 8.1+) 32비트 2GB 4GB

64비트(Windows 8.1+) 64비트 2GB 128TB

64비트(Windows 8까지) 32비트 2GB 4GB

64비트(Windows 8까지) 64비트 2GB 8TB

32비트 시스템에는 아래 그림과 같이 위 표와 결합된 두 가지 변형이 있습니다.

32비트는 4GB를 의미하는데 프로세스는 왜 2GB만 얻나요? 상위 2GB는 모든 커널 장치 드라이버와 코드 및 데이터 측면에서 소비하는 메모리를 포함하여 운영 체제 커널 자체가 상주하는 시스템 공간(커널 공간이라고도 함)이기 때문입니다.

64비트 시스템은 여러 가지 장점을 제공하는데, 그 중 첫 번째는 주소 공간이 크게 증가했다는 것입니다. 64비트의 이론적 한계는 2의 64승, 즉 16엑사바이트입니다. 대부분의 최신 프로세서는 48비트 가상 및 물리적 주소만 지원합니다. 즉, 얻을 수 있는 최대 주소 범위는 2의 48승, 즉 256TB입니다. 즉, 64비트 시스템의 각 프로세스는 128TB의 주소 공간 범위를 가질 수 있고 나머지 128TB는 시스템 공간에 사용될 수 있습니다.

18.9.4 Windows 메모리 통계

개발자는 메모리 사용량 측면에서 프로세스가 어떻게 수행되고 있는지 알고 싶어하는 경우가 많습니다.

다음 표는 Windows 작업 관리(위)에서 볼 수 있는 메모리 사용량 정보입니다.

이름 설명

메모리 사용량 그래프 지난 60초 동안의 물리적 메모리(RAM) 사용량을 표시합니다.

사용 중(압축) 현재 사용 중인 물리적 메모리(압축), 압축된 메모리 양

제출/제출 제한 페이지 파일 확장 전 총 커밋된 메모리/커밋된 메모리 제한

메모리 구성 - 수정됨 디스크에 기록되지 않은 메모리

메모리 구성 - 무료 무료 페이지(대부분 0페이지)

캐시 필요한 경우 용도를 변경할 수 있는 메모리(예비 + 수정)

가능 사용 가능한 물리적 메모리(대기 + 유휴)

페이징 풀/비페이징 풀 커널 풀 메모리 사용량

메모리 압축 정보

현재 필요하지 않은 메모리를 압축하여 메모리를 절약하는 방법으로 메모리 압축이 Windows 10에 추가되었습니다. 이는 CPU를 소비하지 않으므로 UWP 프로세스에 특히 적합하므로 이러한 프로세스에서 사용하는 모든 개인 실제 메모리를 삭제할 수 있습니다. 대신 메모리가 압축되어 다른 프로세스를 위한 여유 페이지가 남습니다. 프로세스가 깨어나면 메모리가 빠르게 압축 해제되어 사용할 준비가 되어 페이지 파일에 대한 I/O를 방지합니다.

Windows 10의 처음 두 버전에서는 압축된 메모리가 시스템 프로세스의 사용자 모드 주소 공간에 저장되었지만 이는 너무 뻔하고 눈에 거슬리므로 Windows 10 버전 1607부터는 특수 프로세스인 메모리 압축(최소화된 프로세스)이 압축된 메모리를 유지하는 프로세스입니다. 또한 작업 관리자에는 이 프로세스가 전혀 표시되지 않지만 프로세스 탐색기와 같은 다른 도구에서는 정상적으로 표시됩니다.

위 이미지의 메모리 구성 막대는 물리적 페이지가 내부적으로 관리되는 방식을 광범위하게 나타냅니다. "사용 중" 부분은 현재 프로세스 및 시스템 작업 세트의 일부로 간주되는 페이지이고, 예비 페이지는 백업이 디스크에 저장되지만 소유 프로세스와의 관계는 유지되는 메모리입니다. 이제 프로세스가 이러한 페이지 중 하나에 닿으면 즉시 작업 세트로 돌아갑니다("사용 중"이 됩니다). 이러한 페이지가 "사용 가능한" 페이지 힙에 즉시 던져지면 페이지를 RAM으로 반환하기 위해 I/O가 필요합니다.

수정된 부분은 콘텐츠가 아직 백업 저장소(일반적으로 페이지 파일)에 기록되지 않은 페이지를 나타내므로 이러한 페이지를 삭제할 수 없습니다. 수정된 페이지 수가 너무 많거나 대기 페이지 및 여유 페이지 수가 너무 적으면 수정된 페이지가 백업 파일에 기록되고 대기 페이지로 이동됩니다.

이러한 모든 변환 및 관리는 I/O를 줄이기 위해 설계되었으며, 아래 이미지에 표시된 것처럼 프로세스 탐색기의 시스템 정보 보기에 있는 메모리 탭에서 이러한 물리적 페이지 목록 관리에 대한 보다 정확한 보기를 사용할 수 있습니다.

위 그림의 페이지 목록 섹션에서는 실제 페이지를 관리하기 위해 실행 메모리 관리에서 사용하는 다양한 목록을 자세히 설명합니다. 제로 페이지는 0만 포함하는 페이지이며, 이는 가비지를 포함하는 무료 페이지에 비해 대부분입니다. 우선 순위 0(이 우선 순위를 가진 유일한 스레드)에서 실행되는 제로 페이지 스레드라고 하는 특수 실행 스레드는 사용 가능한 페이지를 0으로 만드는 스레드입니다. 제로 페이지가 중요한 이유는 해당 프로세스가 더 이상 존재하지 않더라도 할당된 메모리에 다른 프로세스에 속한 데이터가 포함될 수 없다는 보안 요구 사항을 충족하기 위해서입니다. 위 그림의 메모리 구성 중 여유 부분에는 여유 페이지와 제로 페이지의 조합이 포함됩니다.

위 그림의 또 다른 흥미로운 부분은 우선 순위를 기반으로 한 단일 예비 페이지 목록이 아니라 프로세스 탐색기에서 스레드별로 볼 수 있는 메모리 우선 순위라는 8개가 있다는 것입니다. 이 목록도 프로세스 속성이고 기본적으로 각 스레드에 상속됩니다.

메모리 우선순위는 프로세스나 시스템에 물리적 메모리가 필요하기 때문에 대기 목록의 페이지를 여유 페이지로 이동해야 할 때 사용됩니다. 문제는 어떤 페이지를 먼저 해제해야 하는가입니다. 간단한 접근 방식은 프로세스의 작업 세트에서 제거된 첫 번째 페이지가 첫 번째 사용 가능한 페이지인 FIFO 대기열을 사용하는 것입니다. 그러나 이 방법은 너무 단순하며 맬웨어 방지나 백업 애플리케이션과 같이 백그라운드에서 많은 작업을 수행하는 프로세스를 가정합니다. 이러한 프로세스는 분명히 메모리를 사용하지만 사용자가 직접 사용하는 애플리케이션만큼 중요하지는 않습니다. 따라서 실제 메모리가 필요한 경우 가장 최근에 사용된 페이지라도 여유 페이지를 먼저 사용해야 합니다. 이것이 메모리 우선순위가 하는 일입니다.

기본 메모리 우선순위는 5입니다. CPU 우선순위가 4로 감소하고 메모리 우선순위가 1로 감소된 프로세스 및 스레드에 대한 백그라운드 모드로, 프로세스에서 사용하는 예비 페이지가 메모리 우선순위가 더 높은 프로세스보다 먼저 재사용될 가능성이 높아집니다. 백그라운드 모드로 들어가지 않고 메모리 우선순위를 변경하려면 다음 인터페이스를 사용할 수 있습니다.

cpp
BOOL SetProcessInformation(HANDLE hProcess, PROCESS_INFORMATION_CLASS ProcessInformationClass, LPVOID ProcessInformation, DWORD ProcessInformationSize);
BOOL SetThreadInformation(HANDLE hThread, THREAD_INFORMATION_CLASS ThreadInformationClass, LPVOID ThreadInformation, DWORD ThreadInformationSize);

18.9.4.1 Windows 프로세스 메모리 통계

세부 정보 탭에 표시되는 기본 메모리 카운터인 메모리(개인 작업 세트) 또는 메모리(활성 개인 작업 세트)와 함께 작업 관리자의 프로세스 관련 메모리 카운터에 대해 약간의 혼동이 있습니다. 다음 용어를 분석해 보겠습니다.

  • 작업 집합: 프로세스에서 사용하는 실제 메모리입니다.

-개인: 프로세스별 메모리(공유되지 않음).

  • 활성: 백그라운드 UWP 프로세스를 포함하지 않습니다.

이러한 카운터의 문제점은 현재 RAM에 있는 전용 메모리를 나타내는 작업 세트 부분이 프로세스 활동에 따라 위아래로 변동될 수 있는 불안정한 카운터라는 것입니다. 프로세스가 커밋(할당)한 메모리 양을 확인하려는 경우 또는 프로세스에서 메모리가 누수된 경우 이러한 카운터를 살펴볼 필요가 없습니다.

이러한 카운터가 개인 메모리만 표시한다는 사실은 일반적으로 좋은 것입니다. 공유 메모리(예: DLL 코드에서 사용하는 메모리)는 일정하므로 누구도 이에 대해 할 수 있는 일이 없기 때문입니다. 개인 메모리는 프로세스에 의해 제어되는 메모리입니다.

그렇다면 올바른 카운터는 무엇입니까? 커밋 크기입니다. 상황을 더욱 혼란스럽게 만들기 위해 프로세스 탐색기와 성능 모니터는 이 카운터를 전용 바이트라고 부릅니다. 아래 이미지는 커밋 크기와 활성 개인 작업 세트를 커밋 크기별로 정렬하여 나란히 정렬하는 작업 관리자를 보여줍니다.

커밋 크기는 개인 메모리에도 연결되어 있으므로 작업 세트에 없는 메모리를 제외하면 개인 작업 세트와 동일합니다. 두 카운터가 모두 가까우면 프로세스가 매우 활성 상태이고 대부분의 메모리를 사용하고 있거나 Windows의 사용 가능한 메모리가 부족하지 않아 메모리 관리자가 작업 세트에서 페이지를 빠르게 제거할 수 없음을 의미합니다.

어떤 경우에는 두 카운터 간의 차이가 매우 클 수 있습니다. 위 이미지에서 프로세스 코드(PID 34316)에는 작업 세트의 일부가 아닌 커밋된 메모리의 대부분이 있습니다. 이것이 개인 작업 세트 카운터를 보는 것이 오해의 소지가 있는 이유입니다. 프로세스가 약 97MB의 메모리를 소비하는 것처럼 보이지만 실제로는 약 368MB의 메모리를 소비합니다. 물론 현재는 97MB의 RAM만 사용하고 있지만 커밋된 메모리는 커밋된 메모리를 매핑하는 데 사용되는 페이지 테이블을 소비하며 해당 메모리는 시스템의 커밋 제한에 포함됩니다.

작업 관리자의 커밋 크기 열을 사용하여 공유 메모리를 제외한 프로세스의 메모리 소비를 결정하지만 이는 중요하지 않습니다(대부분의 경우). Process Explorer에서 커밋 크기에 해당하는 것은 전용 바이트입니다. 작업 관리자와 프로세스 탐색기에는 더 많은 메모리 관련 열이 포함되어 있습니다(프로세스 탐색기에는 여러 작업 관리자가 포함되어 있습니다). 특히 아래 이미지에 표시된 것처럼 한 열에는 아날로그가 없는 가상 크기 열이 있습니다.

"가상 크기" 열은 유휴 상태가 아닌(즉, 커밋 및 예약된) 모든 페이지, 즉 프로세스에서 소비하는 주소 공간의 양을 계산합니다. 잠재적인 주소 공간이 128TB인 64비트 프로세스의 경우에는 문제가 되지 않지만, 32비트 프로세스의 경우에는 문제가 될 수 있습니다. 커밋된 메모리가 너무 높지 않더라도 예약된 메모리 영역이 크면 새 할당에 사용 가능한 주소 공간이 제한되어 전체 시스템에 여유 메모리가 충분하더라도 할당이 실패할 가능성이 있습니다.

앞서 설명한 카운터에 예약된 메모리가 포함되지 않은 이유가 있습니다. 예약된 메모리는 CPU의 관점에서 여유 메모리와 동일하기 때문에 매우 저렴합니다. 예약된 메모리를 설명하는 데 페이지 테이블이 필요하지 않습니다. 실제로 Windows 8.1부터는 예약된 메모리 비용이 훨씬 더 저렴해졌습니다.

위 이미지의 가상 크기 열에 있는 숫자 중 일부는 약간 우려되는 것처럼 보이며 일부 프로세스의 가상 크기는 약 2TB인 것으로 보입니다. 전용 바이트 열에는 훨씬 작은 숫자가 표시됩니다. 이는 가상 크기로 설명되는 대부분의 메모리 크기가 예약되어 있음을 의미합니다. 일부 프로세스에 그렇게 큰 예약 블록이 있는 실제 이유는 CFG(Control Flow Guard)라는 Windows 10 보안 기능 때문입니다. Process Explorer에 CFG 열을 추가하면 CFG 지원 프로세스와 거대한 2TB 예약 영역 간의 긴밀한 상관 관계를 확인할 수 있습니다.

18.9.5 프로세스 메모리 매핑

프로세스의 주소 공간에는 실행 코드와 전역 데이터, DLL 코드와 전역 정보, 스레드 스택, 힙, 프로세스에서 커밋 및/또는 예약된 기타 메모리 등 메모리 측면에서 프로세스에서 사용하는 모든 것이 포함되어야 합니다. 다음 그림은 프로세스 가상 주소 공간의 일반적인 예를 보여줍니다.

일반적인 프로세스는 수십 개의 DLL을 로드하고 많은 스레드를 사용할 수 있으며 .NET과 같은 프레임워크에는 고유한 DLL과 힙이 있지만 겉보기에는 다르게 보이는 모든 항목은 동일한 "항목"으로 구성됩니다.

프로세스의 실제 메모리 맵을 보려면 System Internals의 VMMap 도구를 사용할 수 있습니다. VMMap을 시작하면 관심 있는 프로세스를 선택할 수 있는 프로세스 선택 대화 상자가 즉시 표시됩니다(아래 이미지). 그러나 VMMap은 여전히 ​​사용자 모드 액세스로 제한되어 있으며 보호된 프로세스를 열 수 없습니다.

프로세스가 선택되면 VMMap의 기본 보기는 세 개의 서로 다른 수평 섹션으로 채워집니다(아래 이미지는 Explorer.exe의 인스턴스를 보여줍니다).

상단에는 3개의 카운터가 표시됩니다.

  • 커밋된 메모리 - 프로세스에서 커밋된 총 메모리(개인 페이지 및 공유 페이지 포함)

  • 전용 바이트 - 전용 커밋 메모리

  • 작업 세트 - 전체 작업 세트(개인 페이지 및 공유 페이지에서 사용하는 물리적 메모리)

각 카운터에는 두 번째 섹션에 표시된 대로 해당 카운터에 포함된 메모리 영역 유형을 보여주는 정렬된 히스토그램이 있습니다. 다음 표에는 VMMap이 표시하는 지역 유형이 요약되어 있습니다.

유형 설명

이미지 이미지 매핑(EXE 및 DLL)

매핑된 파일 매핑 파일(이미지 제외)

공유 가능 페이지 파일 백업을 위한 메모리 매핑 파일

말뚝 힙에서 사용되는 메모리

관리되는 힙 .NET 런타임(CLR 또는 CoreLCR)으로 관리되는 메모리

스택 스레드 스택이 사용하는 메모리

개인 데이터 VirtualAlloc으로 할당된 일반 메모리

사용할 수 없음 사용할 수 없는 메모리 블록(할당 단위가 64KB 미만)

무료 무료 페이지

프로세스 메모리 사용량에 대한 요약 보기가 필요한 경우 PSAPI 함수 GetProcessMemoryInfo가 도움이 될 수 있습니다.

cpp
BOOL GetProcessMemoryInfo(HANDLE Process, PPROCESS_MEMORY_COUNTERS ppsmemCounters, DWORD cb);

이 함수는 PROCESS_QUERY_LIMITED_INFORMATION 또는 PROCESS_QUERY_INFORMATION이 포함된 PROCESS_VM_READ 액세스 마스크가 있어야 하는 프로세스 핸들을 허용합니다. 현재 프로세스 핸들(GetCurrentProcess)은 전체 액세스 마스크를 갖기 때문에 자연스러운 후보입니다.

18.9.6 페이지 보호

프로세스의 가상 주소 공간에 제출된 각 페이지에는 VirtualAlloc 또는 VirtualProtect 기능을 사용하여 설정할 수 있는 보호 플래그가 있습니다. 다음 표에서는 제출된 페이지에 대한 속성을 지정할 수 있는 페이지 보호 속성을 보여줍니다. 페이지 보호를 위반하는 모든 액세스는 액세스 위반 예외가 발생합니다.

보호마크 설명

PAGE_NOACCESS 페이지에 접근할 수 없습니다.

PAGE_READONLY 읽기 액세스만 가능

PAGE_READWRITE 읽기 및 쓰기 액세스

PAGE_WRITECOPY 쓰기 액세스 사본

PAGE_EXECUTE 액세스 수행

PAGE_EXECUTE_READ 실행 및 읽기 액세스

PAGE_EXECUTE_READWRITE 가능한 모든 접근

PAGE_EXECUTE_WRITECOPY 액세스 및 읽기 사본 수행

위의 값 외에도 아래 표와 같이 일부 보호 상수를 추가할 수 있습니다.

보호마크 설명

PAGE_GUARD 보호 페이지. 모든 액세스로 인해 페이지 보호 예외가 발생합니다.

PAGE_NOCACHE 페이지를 캐시할 수 없습니다. 커널 드라이버가 메모리에 액세스하고 드라이버가 필요한 경우에만 사용해야 합니다.

PAGE_WRITECOMBINE 일부 커널 드라이버는 최적화를 사용할 수 있습니다. 일반적으로 사용하면 안 된다

PAGE_TARGETS_INVALID 페이지가 CFG에 대한 잘못된 대상입니다.

PAGE_TARGETS_NO_UPDATE VirtualProtect를 사용하여 보호를 변경할 때 CFG 정보가 업데이트되지 않음

18.9.7 공유 메모리

일반적으로 프로세스에는 혼합되지 않는 별도의 주소 공간이 있지만 때로는 프로세스 간에 메모리를 공유하는 것이 도움이 될 수 있습니다. 일반적인 예로는 DLL이 있으며 NtDll은 모든 사용자 모드 프로세스에 필요하며 가장 필요한 것은 Kernel32.dll, KernelBase.dll, AdvApi32.dll 및 기타 여러 가지입니다. 각 프로세스가 실제 메모리에 자체 DLL 복사본을 가지고 있다면 빠르게 소모될 것입니다. 실제로 DLL을 소유하는 첫 번째 동기 중 하나는 공유(적어도 코드) 능력입니다. 일반적으로 코드는 읽기 전용이므로 EXE 파일의 실행 코드와 마찬가지로 공유해도 안전합니다. 동일한 이미지 파일을 기반으로 여러 프로세스가 실행되고 있다면 공유하지 않을 이유가 없습니다. 아래 그림의 kernel32.dll은 두 프로세스 간에 공유됩니다.

모든 코드가 재배치 가능한 것은 아니기 때문에 위 그림에서 DLL을 공유하는 모든 프로세스에서 DLL의 가상 주소가 동일해야 합니다. 전역 범위에서 다음과 같이 변수를 선언하면:

cpp
int x;
void main() 
{
    x++;
    //...
}

이 실행 파일의 두 인스턴스를 실행하는 경우 두 번째 인스턴스의 x 값은 무엇입니까? 대답은 1입니다. x는 시스템의 전역 데이터가 아닌 프로세스의 전역 데이터이며 DLL과 동일한 방식으로 작동합니다. DLL이 전역 변수를 선언하면 DLL을 로드하는 모든 프로세스에만 전역 변수가 적용됩니다.

대부분의 경우 이는 기록 중 복사(PAGE_WRITECOPY)라는 페이지 보호를 사용하여 달성되는 바로 우리가 원하는 것입니다. 아이디어는 동일한 변수(해당 프로세스에서 사용하는 실행 파일 또는 DLL에 선언됨)를 사용하는 모든 프로세스가 해당 변수가 있는 페이지를 동일한 실제 페이지(위 그림)에 매핑한다는 것입니다. 프로세스가 이 변수의 값을 변경하면(아래 이미지의 프로세스 A) 예외가 발생하여 메모리 관리자가 페이지의 복사본을 생성하고 이를 호출 프로세스에 개인 페이지로 전달하여 쓰기 중 복사 보호를 제거합니다(아래 이미지의 3페이지).

이를 사용하는 모든 프로세스에 전역 데이터를 복사하는 것이 더 간단하지만 실제 메모리가 낭비됩니다. 데이터가 변경되지 않은 경우 복사할 필요가 없습니다. 어떤 경우에는 프로세스 간에 데이터를 공유해야 합니다. 비교적 간단한 메커니즘은 전역 변수를 사용하는 것이지만 페이지가 PAGE_WRITECOPY가 아닌 일반 PAGE_READWRITE로 보호되도록 지정하는 것입니다. 이는 실행 파일이나 DLL에 새 데이터 세그먼트를 구성하고 필수 속성을 지정하여 달성할 수 있습니다. 아래 코드는 이를 달성하는 방법을 보여줍니다.

cpp
#pragma data_seg("shared")
int x = 0;
#pragma data_seg()

#pragma comment(linker, "/section:shared,RWS")

data_seg pragma는 PE에 새 세그먼트를 생성합니다. 이름은 무엇이든 가능하며(최대 8자) 명확성을 위해 위 코드에서는 "공유"라고 합니다. 공유해야 하는 모든 변수는 해당 섹션에 배치되며 명시적으로 초기화되어야 합니다. 그렇지 않으면 해당 섹션에 저장되지 않습니다. 기술적으로 여러 변수가 있는 경우 첫 번째 변수만 명시적으로 초기화하면 됩니다. 그러나 모두 초기화하는 것이 가장 좋습니다. 두 번째 #pragma는 RWS 속성(읽기, 쓰기, 공유)이 있는 섹션을 생성하라는 링커에 대한 지시문입니다. 작은 "S"가 핵심입니다. 일단 이미지가 매핑되면 PAGE_WRITECOPY 보호가 적용되지 않으므로 동일한 PE를 사용하는 모든 프로세스 간에 공유됩니다.

18.9.8 페이지 파일

프로세서는 물리적 메모리(RAM)에 있는 코드와 데이터에만 액세스할 수 있습니다. 일부 실행 파일이 시작되면 Windows는 실행 파일의 코드와 데이터(NTdll.dll 포함)를 프로세스의 주소 공간에 매핑합니다. 그런 다음 프로세스의 첫 번째 스레드가 실행을 시작하여 실행하는 코드(먼저 NtDll.dll에서 실행 파일)가 물리적 메모리에 매핑되고 디스크에서 로드되어 CPU가 이를 실행할 수 있게 됩니다.

프로세스의 스레드가 모두 대기 상태에 있다고 가정합니다. 프로세스에 사용자 인터페이스가 있고 사용자가 애플리케이션 창을 최소화하고 한동안 애플리케이션을 사용하지 않았을 수도 있습니다. Windows에서는 실행 파일이 사용하는 RAM을 필요한 다른 프로세스에 맞게 용도 변경할 수 있습니다. 이제 사용자가 애플리케이션 창을 복원한다고 가정해 보겠습니다. 이제 Windows는 애플리케이션 코드를 다시 RAM으로 가져와야 합니다. 코드는 어디서 읽나요? 실행 파일 자체.

실행 파일과 DLL이 자체 백업이라는 의미입니다. 실제로 Windows는 실행 파일 및 DLL에 대해 메모리 매핑된 파일을 생성합니다. 이는 또한 이러한 파일에 하나 이상의 열린 핸들이 있기 때문에 이러한 파일을 삭제할 수 없는 이유도 설명합니다.

데이터는 어떻습니까? 일부 데이터에 오랫동안 액세스하지 않은 경우(또는 Windows에서 사용 가능한 메모리가 부족한 경우) 메모리 관리자는 데이터를 디스크 - **페이지 파일(Page File)**에 쓸 수 있습니다. 페이지 파일은 커밋된 전용 메모리의 백업으로 사용되며 페이지 파일을 사용할 필요가 없습니다. Windows는 페이지 파일 없이도 정상적으로 실행될 수 있지만 한 번에 커밋할 수 있는 메모리 양이 줄어듭니다.

또한 Windows는 최대 16개의 페이지 파일을 지원합니다. 이 파일은 다른 디스크 파티션에 있어야 하며 이름이 pagefile.sys이고 루트 파티션에 있어야 합니다(해당 파일은 기본적으로 숨겨져 있습니다). 한 파티션이 너무 꽉 차거나 다른 파티션이 별도의 물리적 디스크인 경우 여러 페이지 파일을 사용하여 I/O 처리량을 늘리는 것이 도움이 될 수 있습니다.

작업 관리자에 표시되는 커밋 제한은 기본적으로 RAM 용량에 모든 페이지 파일의 현재 크기를 더한 것입니다. 각 페이지 파일에는 초기 크기와 최대 크기가 있을 수 있습니다. 시스템이 커밋 제한에 도달하면 페이지 파일이 구성된 최대값으로 증가하므로 이제 커밋 제한도 늘어납니다(더 많은 I/O로 인해 성능이 저하될 수 있음). 커밋된 메모리가 원래 커밋 제한보다 낮아지면 페이지 파일 크기가 다시 원래 크기로 줄어듭니다.

페이지 파일 크기는 시스템 속성, 고급 시스템 설정, 성능 섹션의 설정, 고급 탭, 마지막으로 가상 메모리 섹션에서 변경...을 선택하여 구성할 수 있습니다. 그러면 아래와 같은 대화 상자가 나타납니다. 마지막 버튼을 클릭하기 전에 페이지 파일의 현재 크기가 버튼 근처에 표시된다는 점에 유의하세요.

18.9.9 가상 메모리

기존 가상 메모리에는 가상 주소, 페이지 테이블 항목, 세그먼트 테이블 항목 및 이들의 조합과 같은 개념과 방법이 포함됩니다.

18.9.9.1 가상 메모리 개요

각 프로세스에는 비어 있는 상태로 시작하는 자체 가상 개인 선형 주소 공간이 있습니다(또는 실행 가능 이미지와 NtDll.Dll이 일반적으로 먼저 매핑되므로 거의 비어 있음). 메인(첫 번째) 스레드가 실행을 시작하면 메모리가 할당되고 더 많은 DLL이 로드될 수 있습니다. 이 주소 공간은 비공개이므로 다른 프로세스가 직접 액세스할 수 없습니다. 주소 공간 범위는 0(주소의 처음 64KB는 기술적으로 할당할 수 없음)부터 시작하여 다음과 같이 프로세스 "비트"(32 또는 64비트), 운영 체제 "비트" 및 링커 플래그에 따라 최대값까지 올라갑니다.

  • 32비트 Windows 시스템의 32비트 프로세스의 경우 프로세스 주소 공간 크기는 기본적으로 2GB입니다.

  • 32비트 Windows 시스템에서 "사용자 주소 공간 증가" 설정을 사용하는 32비트 프로세스의 경우 프로세스 주소 공간 크기는 특정 설정에 따라 최대 3GB까지 가능합니다. 확장된 주소 공간 범위를 얻으려면 프로세스를 만든 실행 파일의 헤더에 LargeAddressWare 링커 플래그가 표시되어야 합니다. 그렇지 않은 경우에도 2GB로 제한됩니다.

  • 64비트 프로세스(64비트 Windows 시스템에서는 당연히)의 경우 주소 공간 크기는 8TB(Windows 8 이하) 또는 128TB(Windows 7.1 이상)입니다.

  • 64비트 Windows 시스템의 32비트 프로세스의 경우 실행 가능 이미지가 LargeAddressWare 플래그와 연결되면 주소 공간 크기는 4GB입니다. 그렇지 않으면 크기는 2GB로 유지됩니다.

메모리 자체를 가상이라고 합니다. 이는 주소 범위와 실제 메모리(RAM)의 정확한 위치 사이에 간접적인 관계가 있음을 의미합니다. 프로세스의 버퍼는 실제 메모리에 매핑되거나 파일(예: 페이지 파일)에 임시로 상주할 수 있습니다. "가상"이라는 용어는 실행 관점에서 액세스되는 메모리가 RAM에 있는지 여부를 알 필요가 없음을 의미합니다. 메모리가 실제로 RAM에 매핑된 경우 CPU는 데이터에 직접 액세스합니다. 그렇지 않으면 CPU에서 페이지 오류 예외가 발생하여 메모리 관리자의 페이지 오류 처리기가 적절한 파일에서 데이터를 추출하고 이를 RAM에 복사한 다음 매핑된 버퍼의 페이지 테이블 항목에서 필요한 변경을 수행하고 CPU에 다시 시도하도록 지시합니다.

가상 메모리는 메인 메모리에 컴파일되지 않는 프로세스를 실행할 수 있게 해주는 기술이다. 사용자의 논리적 메모리와 물리적 메모리를 분리합니다. 이러한 분리를 통해 작은 실제 메모리만 사용할 수 있는 경우에도 프로그램에 매우 큰 메모리를 사용할 수 있습니다. 가상 메모리를 사용하면 프로그래머가 더 이상 사용 가능하거나 사용할 수 없는 물리적 메모리의 양을 계산할 필요가 없으므로 프로그래밍 작업이 더 쉬워지고, 일반적으로 요청 페이징을 통해 구현되는 페이지 공유를 통해 다양한 프로세스에서 파일과 메모리를 공유할 수 있습니다.

프로세스의 가상 주소 공간은 프로세스가 메모리에 저장되는 방식에 대한 논리적(또는 가상) 보기를 나타냅니다. 일반적으로 이 뷰는 프로세스가 주소 0과 같은 일부 논리 주소에서 시작하고 연속 메모리에 존재한다는 것을 의미합니다.

18.9.9.2 페이징

요청 페이징 시스템은 스왑 기능이 있는 페이징 시스템과 유사합니다. 프로세스를 실행하려면 이를 메모리로 스왑합니다. 스왑 프로그램은 전체 프로세스를 운영하는데, 페이저(Pager)는 프로세스의 각 페이지를 포함합니다. 요청 페이징의 개념은 스왑 프로그램 대신 페이저를 사용하는 것입니다. 프로세스가 교체되면 호출기는 프로세스를 다시 교체하기 전에 어떤 페이지가 사용될지 추측합니다. 프로세스 전체를 교체하는 대신 페이저는 필요한 페이지만 메모리에 넣습니다. 페이징된 메모리를 인접한 디스크 공간으로 전송하는 과정은 아래 그림과 같습니다.

따라서 사용할 수 없는 메모리 페이지를 읽는 것을 방지하여 스왑 시간과 필요한 물리적 메모리 양을 줄입니다. 이 기술에서는 메모리 페이지와 디스크 페이지를 구별하기 위한 하드웨어 지원이 필요합니다. 이를 위해 하드웨어는 유효한 비트와 유효하지 않은 비트를 사용합니다. 비트가 유효로 설정되면 관련 페이지가 메모리에 있음을 의미합니다. 비트가 유효하지 않음으로 설정되면 페이지가 유효하지 않거나 유효하지만 현재 디스크에 없음을 의미합니다.

페이지를 유효하지 않은 것으로 표시하는 것은 프로세스가 페이지에 액세스를 시도하지 않는 경우 아무런 효과가 없습니다. 따라서 프로세스가 실행되어 메모리에 있는 페이지에 액세스하면 정상적으로 실행이 진행됩니다. 잘못된 것으로 표시된 페이지에 액세스하면 페이지 오류 트랩(트랩)이 발생하며 운영 체제가 필요한 페이지를 메모리에 배치할 수 없기 때문에 발생합니다.

프로세스가 참조하는 페이지가 실제 메모리에 없는 경우 다음 그림과 결합됩니다.

  1. 이 프로세스의 내부 테이블(페이지 테이블)을 확인하여 참조가 유효한지 확인합니다.

  2. 참조가 유효하지 않은 경우 프로세스를 종료합니다. 참조가 유효하지만 도입되지 않은 경우 주 메모리에서 도입되어야 합니다.

  3. 이제 메모리에서 사용 가능한 프레임을 찾습니다.

  4. 그런 다음 새로 할당된 프레임으로 필요한 페이지를 읽어옵니다.

  5. 디스크 읽기가 완료되면 페이지가 이제 메모리에 있음을 나타내도록 내부 테이블을 수정합니다.

  6. 불법 주소 캐처에 의해 중단된 명령을 다시 시작합니다. 이제 프로세스는 마치 메모리에 있는 것처럼 페이지에 액세스할 수 있습니다.

페이징 요청을 위해서는 페이징 및 스와핑과 동일한 하드웨어가 필요합니다.

  • 페이지 테이블: 유효-무효 비트를 통해 항목을 유효하지 않은 것으로 표시하는 기능.

  • 보조 메모리 : 메인 메모리에 존재하지 않는 페이지를 저장합니다. 고속 디스크입니다.

요청 페이징은 컴퓨터 시스템 성능에 상당한 영향을 미칠 수 있습니다. P(0<=P<=1)를 페이지 오류 확률이라고 하고 유효 액세스 시간은 (1-P) ma + P 페이지 오류입니다. 여기서 P = 페이지 오류, ma = 메모리 액세스 시간입니다. 이는 유효 접속 시간이 페이지 실패율에 비례함을 보여줍니다. 수요 페이징에서는 페이지 오류율을 낮게 유지하는 것이 중요합니다.

페이지 오류로 인해 다음과 같은 일련의 작업이 발생합니다.

  1. 운영 체제 트랩.

  2. 사용자 등록 및 처리현황을 저장합니다.

  3. 중단이 페이지 폴트인지 확인하십시오.

  4. 페이지 참조가 올바른지 확인하고 디스크에서 페이지의 위치를 ​​결정합니다.

  5. 디스크에서 유휴 프레임으로 읽기를 실행합니다.

  6. 대기 중인 경우 다른 사용자에게 CPU를 할당합니다.

  7. 디스크에서 인터럽트.

  8. 다른 사용자의 등록 및 처리 상태를 저장합니다.

  9. 인터럽트가 디스크에서 나오는지 확인하십시오.

  10. 필요한 페이지가 현재 메모리에 있음을 표시하도록 페이지 테이블과 기타 테이블을 수정합니다.

  11. CPU가 프로세스에 다시 할당될 때까지 기다립니다.

  12. 사용자 등록 프로세스 상태와 새 페이지 테이블을 복원한 다음 중단된 명령을 재개합니다.

페이지 교체 전략:

  • 각 프로세스에는 프로세스의 페이지(데이터)를 보유하는 프레임(메모리)이 할당됩니다.

  • 프레임은 필요에 따라 페이지를 채웁니다. 이를 요청 페이징이라고 합니다.

  • 페이지를 교체하도록 페이지 폴트 서비스 루틴을 수정하여 메모리 과잉 할당을 방지할 수 있습니다.

  • 페이지 교체 알고리즘의 작업은 새 페이지를 위한 공간을 만들기 위해 어떤 페이지가 손상되었는지 확인하는 것입니다.

  • 페이지 교체를 통해 논리적 메모리와 물리적 메모리의 분리가 완료됩니다.

아래 그림과 결합하여 페이지 교체에 대한 설명은 다음과 같습니다.

  • 요구 페이징은 사용되지 않는 페이지를 로드하지 않음으로써 다중 프로그래밍 수준을 높이고 한 번에 더 많은 프로세스를 실행할 수 있도록 합니다.

  • 페이지 교체 정책은 메모리의 페이지를 도입해야 하는 새 페이지로 교체하는 솔루션을 다룹니다. 사용자 프로세스가 실행되는 동안 페이지 폴트가 발생합니다.

  • 하드웨어 트랩은 운영 체제로 전달되어 내부 테이블을 검사하여 불법적인 메모리 액세스가 아닌 페이지 오류인지 확인합니다.

  • 운영 체제는 디스크에서 파생된 페이지의 위치를 ​​확인하고 사용 가능한 프레임 목록에 사용 가능한 프레임이 없음을 찾습니다.

  • 모든 프레임이 주 메모리에 있고 페이지 결함을 충족하기 위해 새 페이지를 가져와야 하는 경우 교체 전략은 현재 메모리에 있는 교체할 페이지를 선택하는 것과 관련됩니다.

  • 삭제할 i, e 페이지는 향후 참조 가능성이 가장 낮은 i, e 페이지여야 합니다.

  • 사용자 프로세스가 실행되는 동안 페이지 폴트가 발생했습니다. 운영 체제는 필요한 페이지가 디스크에서 어디에 있는지 확인하지만 여유 프레임 목록에 여유 프레임이 없음을 발견합니다. 즉, 위 이미지와 같이 모든 메모리가 사용 중입니다. 이 시점에서 운영 체제에는 여러 가지 옵션이 있습니다. 프로세스를 종료하거나 모든 프레임을 해제하고 다중 프로그래밍 수준을 줄여 프로세스를 교체할 수 있습니다.

아래 그림과 결합하면 페이지 교체 알고리즘의 과정은 다음과 같습니다.

  1. 디스크에서 파생된 페이지의 위치를 찾습니다.

  2. 무료 프레임을 찾으세요. 유휴 프레임이 있는 경우 이를 사용합니다. 그렇지 않으면 대체 알고리즘을 사용하여 후보 페이지를 선택합니다. 후보 페이지를 디스크에 기록하고 이에 따라 페이지 및 프레임 테이블을 변경합니다.

  3. 필요한 페이지를 프리 프레임으로 읽어들여 페이지와 프레임 테이블을 변경합니다.

  4. 사용자 프로세스를 다시 시작합니다.

**후보 페이지(피해자 페이지)**는 물리적 메모리가 부족할 때 지원되는 페이지입니다. 유휴 프레임이 없는 경우 두 페이지 전환(출력 및 하나의 입력)을 읽으면 유효 액세스 시간이 표시됩니다. 각 페이지나 프레임에는 하드웨어와 관련된 더티(수정된) 비트가 있을 수 있으며 페이지의 단어나 바이트가 기록될 때마다 하드웨어는 페이지의 수정된 비트를 설정하여 페이지가 수정되었음을 나타냅니다. 교체할 페이지를 선택하면 해당 페이지의 수정 비트가 확인됩니다. 비트가 설정된 경우 디스크에서 읽었기 때문에 페이지가 수정된 것입니다. 비트가 설정되지 않은 경우 페이지를 메모리로 읽은 이후 페이지가 수정되지 않은 것입니다. 따라서 페이지 복사본이 수정되지 않은 경우 이미 존재하는 메모리 페이지를 디스크에 쓰는 것을 피할 수 있습니다. 하지만 일부 페이지는 수정할 수 없습니다.

요청 페이지를 구현하려면 두 가지 주요 문제를 해결해야 합니다.

  • 프레임 할당 알고리즘과 페이지 교체 알고리즘을 개발해야 합니다.

  • 메모리에 프로세서가 여러 개 있는 경우 할당할 프레임 수와 교체할 페이지 수를 결정해야 합니다.

18.9.9.3 페이지 교체

페이지 교체 알고리즘은 메모리 페이지를 할당해야 할 때 페이지로 교체할 메모리 페이지를 결정합니다. 페이지 교체 알고리즘에는 3가지가 있습니다.

페이지 오류: CPU가 요청한 페이지가 메모리에 존재하지 않습니다.

페이지 히트: CPU가 요청한 페이지가 메모리에 존재합니다.

  1. FIFO(선입 선출) 알고리즘

FIFO는 각 페이지가 메모리에 저장되는 시간을 연관시키는 가장 간단한 페이지 교체 알고리즘입니다. 페이지가 교체될 때 가장 오래된 페이지가 선택되어 대기열의 맨 앞에 배치되고, 페이지가 메모리에 들어오면 대기열의 끝에 삽입됩니다. 예: 프레임이 처음에 비어 있는 다음 인용 문자열을 고려하십시오.

처음 세 참조(7,0,1)는 페이지 오류가 발생하여 빈 프레임에 배치됩니다. 7페이지가 먼저 소개되었기 때문에 다음 참조 페이지 2가 7페이지를 대체합니다. 0은 다음 참조이고 0은 이미 메모리에 있으므로 e에 대한 페이지 폴트는 없습니다. 3에 대한 다음 참조로 인해 페이지 0이 대체되므로 0에 대한 다음 참조로 인해 문자열 끝까지 지속되는 페이지 오류가 발생합니다. 총 15개의 페이지 오류가 있습니다.

분석: 참조 페이지 수 = 20, 페이지 부재 수 = 15, 페이지 조회 수 = 5, 페이지 적중률 = 페이지 조회 수/총 페이지 참조 수 = (5 / 20)x100 = 25%

, 페이지 폴트율 = 페이지 폴트 수 / 총 페이지 참조 수 = (15 / 20)x100= 75%.

Belady의 Anamoly 문제: 일부 페이지 교체 알고리즘의 경우 할당된 프레임 수가 증가하면 페이지 오류가 증가할 수 있습니다. FIFO 교체 알고리즘은 이 문제에 직면할 수 있습니다.

최적 알고리즘: 최적 페이지 교체 알고리즘은 주로 Belady의 Anamoly 문제를 해결합니다. 이상적으로는 페이지 오류율이 가장 낮은 알고리즘을 선택하려고 합니다. 이러한 알고리즘이 존재하며 최적의 알고리즘으로 알려져 있습니다. 프로세스: 가장 오랜 시간 동안 사용되지 않을(또는 전혀 사용되지 않을) 페이지를 교체합니다. 즉, 참조 문자열에서 앞쪽 거리가 가장 큰 페이지를 교체합니다. 예: 프레임이 처음에 비어 있는 다음 인용 문자열을 고려하십시오.

처음 3개의 인용은 3개의 빈 프레임을 채우는 오류를 일으키고, 참조 18만 7페이지를 사용하기 때문에 2페이지의 참조가 7페이지를 대체합니다. 0페이지는 5페이지에 사용되고 1페이지는 14페이지에 사용됩니다. 페이지 오류가 9개만 있으면 최적의 교체가 오류가 15개 있는 FIFO보다 훨씬 낫습니다. 이 알고리즘은 향후 참조 문자열에 대한 지식이 필요하기 때문에 구현하기 어렵습니다.

분석: 참조 페이지 수 = 20, 페이지 오류 수 = 9, 페이지 조회 수 = 11, 페이지 적중률 = 페이지 조회 수/총 페이지 인용 수 = (11 / 20)x100 = 55%, 페이지 오류율 = 페이지 오류 수 / 총 페이지 인용 수 = (9 / 20)x100 = 45%.

  1. LRU(가장 최근에 사용됨) 알고리즘

최적의 알고리즘이 실현 가능하지 않은 경우 최적의 알고리즘을 근사화할 수 있습니다. OPTS와 FIFO의 주요 차이점은 FIFO 알고리즘은 페이지에 내장된 시간을 사용하고 OPT는 페이지에서 사용하는 시간을 사용한다는 것입니다. LRU 알고리즘은 가장 오랫동안 사용되지 않은 페이지를 교체하고 해당 페이지를 마지막으로 사용된 시간과 연결합니다. 이 전략은 앞이 아닌 뒤를 보는 최적의 페이지 교체 알고리즘입니다. 예: 프레임이 처음에 비어 있는 다음 인용 문자열을 고려하십시오.

상위 5개 오류는 최상의 대체 오류와 유사합니다. 페이지 4가 참조되면 LRU는 세 프레임 중 가장 최근에 사용된 프레임인 페이지 2를 봅니다. 가장 최근에 사용한 페이지는 3페이지가 사용되기 직전인 0페이지입니다. LRU 전략은 페이지 교체 알고리즘으로 자주 사용되며 좋은 선택으로 간주됩니다.

분석: 참조 페이지 수 = 20, 페이지 오류 수 = 12, 페이지 조회 수 = 8, 페이지 적중률 = 페이지 조회 수 / 총 페이지 참조 수 = (8 / 20) x100 = 40%, 페이지 오류율 = 페이지 오류 수 / 총 페이지 참조 수 = (12 / 20) x100 = 60%.

매우 큰 주소의 메모리 공간을 지원하기 위해 다중 레벨 페이지 테이블을 사용할 수 있습니다. 아래 그림 a는 2개의 페이지 테이블 필드가 있는 32비트 주소이고, b는 2레벨 페이지 테이블입니다.

4가지 일반적인 페이지 교체 알고리즘의 동작 비교 차트:

고정 할당 및 부분 페이지 교체 알고리즘의 다양한 알고리즘 비교:

18.9.9.4 세그먼트

앞에서 설명한 가상 메모리는 가상 주소의 범위가 0부터 최대 주소까지 차례로 1차원이므로 1차원적입니다. 많은 문제의 경우 하나보다 두 개 이상의 별도 가상 주소 공간을 갖는 것이 훨씬 나을 수 있습니다. 예를 들어, 컴파일러에는 컴파일 중에 생성되는 다음과 같은 많은 테이블이 있습니다.

  • 인쇄 목록을 위해 저장된 소스 텍스트(배치 시스템에서).

  • 변수의 이름과 속성을 포함하는 기호 테이블입니다.

  • 사용된 모든 정수 및 부동 소수점 상수를 포함하는 테이블입니다.

  • 프로그램의 구문 분석을 포함하는 구문 분석 트리입니다.

  • 프로시저 호출을 위한 컴파일러 내의 스택입니다.

처음 4개의 테이블은 각각 ​​컴파일이 진행됨에 따라 계속해서 증가합니다. 마지막 항목은 컴파일 중에 예측할 수 없는 방식으로 늘어나거나 줄어듭니다. 1차원 메모리에서는 아래 그림과 같이 이 5개 테이블에 가상 주소 공간의 연속 블록이 할당되어야 합니다.

프로그램에 평소보다 훨씬 더 많은 수의 변수가 있지만 다른 모든 변수에는 정상적인 숫자가 있는 경우 어떤 일이 일어날지 생각해 보십시오. 심볼 테이블에 할당된 주소 공간 블록은 가득 찼을 수 있지만 다른 테이블에는 충분한 공간이 있을 수 있습니다. 필요한 것은 가상 메모리가 프로그램을 오버레이로 구성하는 걱정을 없애주는 것처럼 프로그래머가 테이블 확장 및 축소를 관리하지 않아도 되도록 하는 방법입니다.

간단하고 일반적인 해결책은 세그먼트라고 불리는 완전히 독립적인 주소 공간을 머신에 제공하는 것입니다. 각 세그먼트는 0부터 시작하여 최대값까지 올라가는 선형 주소 시퀀스로 구성됩니다. 각 세그먼트의 길이는 0부터 허용되는 최대 주소까지의 값이 될 수 있습니다. 서로 다른 세그먼트는 (일반적으로) 길이가 다를 수 있습니다. 또한 실행 중에 세그먼트 길이가 변경될 수 있습니다. 스택 세그먼트의 길이는 무언가가 스택에 푸시될 때마다 증가하고 스택에서 무언가가 팝될 때마다 감소할 수 있습니다.

각 세그먼트는 별도의 주소 공간을 형성하므로 서로 다른 세그먼트는 서로 영향을 주지 않고 독립적으로 늘어나거나 줄어들 수 있습니다. 세그먼트의 스택이 성장하기 위해 더 많은 주소 공간이 필요한 경우 해당 주소 공간에 연결할 다른 항목이 없습니다. 물론 세그먼트가 가득 찰 수도 있지만 일반적으로 세그먼트가 매우 크기 때문에 이런 일이 거의 발생하지 않습니다. 이 세그먼트 또는 2차원 메모리에 주소를 지정하려면 프로그램은 두 부분으로 구성된 주소, 세그먼트 번호 및 세그먼트 내의 주소를 제공해야 합니다. 다음 그림은 앞에서 설명한 컴파일러 테이블에 사용된 세그먼트화된 메모리를 보여줍니다. 여기서는 5개의 개별 세그먼트를 보여줍니다.

세그먼트 메모리를 사용하면 각 테이블을 독립적으로 늘리거나 줄일 수 있습니다.

세그먼트는 프로그래머가 논리적 엔터티로 알고 사용하는 논리적 엔터티입니다. 세그먼트에는 프로시저, 배열, 스택 또는 스칼라 변수 세트가 포함될 수 있지만 일반적으로 서로 다른 유형이 혼합되어 포함되지는 않습니다.

세그먼트 메모리에는 데이터 구조의 증가 또는 축소 처리를 단순화하는 것 외에도 다른 장점이 있습니다. 개별적으로 컴파일된 프로시저의 링크는 각 프로시저가 주소 0에서 시작하는 별도의 세그먼트를 점유하는 경우 크게 단순화됩니다. 프로그램을 구성하는 모든 프로시저가 컴파일되고 링크된 후 세그먼트 n의 프로시저에 대한 프로시저 호출은 두 부분으로 구성된 주소(n, 0)를 사용하여 워드 0(진입점)을 지정합니다.

이후에 세그먼트 n의 프로시저를 수정하고 다시 컴파일하면 새 버전이 이전 버전보다 크더라도 다른 프로시저를 변경할 필요가 없습니다(시작 주소가 수정되지 않기 때문). 1차원 메모리에서는 프로세스가 서로 간에 주소 공간 없이 긴밀하게 묶여 있습니다. 따라서 한 프로세스의 크기를 변경하면 세그먼트에 있는 다른 모든(관련되지 않은) 프로세스의 시작 주소에 영향을 미칠 수 있습니다. 그러면 이동 프로시저를 호출하는 모든 프로시저를 수정하여 새로운 시작 주소를 통합해야 합니다. 프로그램에 수백 개의 프로시저가 포함되어 있으면 이 프로세스에 비용이 많이 들 수 있습니다.

세분화는 여러 프로세스 간에 프로그램이나 데이터를 공유하는 데도 도움이 됩니다. 일반적인 예는 공유 라이브러리입니다. 고급 윈도우 시스템을 실행하는 최신 워크스테이션에는 일반적으로 거의 모든 프로그램에 매우 큰 그래픽 라이브러리가 있습니다. 세그먼트화된 시스템에서 그래픽 라이브러리는 세그먼트에 배치되고 여러 프로세스에 의해 공유될 수 있으므로 각 프로세스의 주소 공간에 그래픽 라이브러리를 가질 필요가 없습니다. 순수 페이징 시스템에서 공유 라이브러리를 가질 수는 있지만 더 복잡합니다. 실제로 이러한 시스템은 세분화를 시뮬레이션하여 구현됩니다.

각 세그먼트는 프로시저나 배열과 같이 프로그래머에게 알려진 논리적 엔터티를 형성하기 때문에 세그먼트마다 보호 유형이 다를 수 있습니다. 프로시저 섹션을 실행 전용으로 지정하여 프로시저 섹션을 읽거나 저장하려는 시도를 비활성화할 수 있습니다. 부동 소수점 배열은 읽기/쓰기로 지정할 수 있지만 실행할 수는 없습니다. 배열로 점프하려는 시도가 포착됩니다. 이 보호는 오류를 포착하는 데 도움이 됩니다. 다음 표에서는 페이징과 분할을 비교합니다.

질문 페이지 매김 세분화

프로그래머는 이 기술이 사용되고 있다는 사실을 알아야 합니까? 아니요 예

선형 주소 공간은 얼마나 됩니까? 1 많이

총 주소 공간이 물리적 메모리 크기를 초과할 수 있나요? 예 예

프로그램과 데이터를 별도로 구분하여 보호할 수 있나요? 아니요 예

다양한 크기의 테이블을 쉽게 수용할 수 있나요? 아니요 예

사용자 간 프로그램 공유가 용이합니까? 아니요 예

이 기술은 왜 발명되었나요? 추가 물리적 메모리를 구입하지 않고도 더 큰 선형 주소 공간을 확보할 수 있습니다. 프로그램과 데이터를 논리적으로 독립된 주소 공간으로 분할할 수 있으며 공유 및 보호에 도움이 됩니다.

스테이징 구현은 페이징과 근본적으로 다릅니다. 즉, 페이지 크기는 고정되어 있지만 스테이징은 고정되어 있지 않습니다. 아래 그림 (a)는 초기에 5개의 세그먼트를 포함하는 물리적 메모리의 예를 보여줍니다. 이제 세그먼트 1이 제거되고 더 작은 세그먼트 7이 다시 배치되면 어떻게 되는지 생각해 보십시오. (b)의 메모리 구성을 도출합니다. 세그먼트 7과 세그먼트 2 사이에는 사용되지 않은 영역인 구멍이 있습니다. 그런 다음 (c)와 같이 세그먼트 4가 세그먼트 5로 대체되고, (d)와 같이 세그먼트 3이 세그먼트 6으로 대체됩니다. 시스템이 한동안 실행된 후 메모리는 블록으로 나누어지며, 일부는 세그먼트를 포함하고 일부는 구멍을 포함합니다. 이러한 현상을 테셀레이션 또는 외부 단편화라고 하며 홀에 메모리를 낭비합니다. (e)와 같이 압축하여 처리할 수 있다.

(a)-(d) 체스판 형성. (e) 압축에 의한 체커보드 제거.

동적 파티셔닝의 효과.

다음은 Intel x86의 페이징 및 세그멘테이션 기술에 대해 설명합니다. x86-64까지 x86의 가상 메모리 시스템은 분할 및 페이징을 포함한 여러 측면에서 MULTICS와 유사했습니다. MULTICS에는 256K 독립 세그먼트가 있으며 각 세그먼트는 최대 64K 36비트 워드를 보유할 수 있는 반면, x86에는 16K 독립 세그먼트가 있으며 각 독립 세그먼트는 최대 10억 32비트 워드를 보유할 수 있습니다. 세그먼트 수는 적지만 1000개 이상의 세그먼트가 필요한 프로그램은 거의 없지만 더 큰 세그먼트가 필요한 프로그램이 많기 때문에 더 큰 세그먼트 크기가 더 중요합니다. x86-64부터 분할은 더 이상 사용되지 않는 것으로 간주되어 레거시 모드를 제외하고 더 이상 지원되지 않습니다. 이전 분할 메커니즘의 일부 잔존물은 대부분 호환성을 위해 x86-64의 기본 모드에서 계속 사용할 수 있지만 더 이상 동일한 역할을 수행하지 않으며 더 이상 진정한 분할을 제공하지 않습니다.

18.9.9.5 가상 메모리 변환

가상 주소를 물리적 주소 자체로 변환하는 작업은 자동으로 수행되므로 CPU가 다음과 같은 명령을 볼 때:

cpp
mov eax, [100000H]

주소 0x100000은 물리적이지 않고 가상이라는 것을 알고 있습니다(CPU가 보호 모드/장기 모드에서 실행되도록 구성되어 있기 때문입니다). 이제 CPU는 RAM(있는 경우)의 페이지 위치를 설명하는 메모리 관리자의 미리 준비된 테이블을 살펴봐야 합니다. RAM에 없으면(변환 테이블에서 CPU가 확인한 유효한 비트에 0으로 표시됨) 페이지 오류 예외가 발생하며 이는 메모리 관리자에 의해 적절하게 처리됩니다. 주소 변환과 관련된 기본 구성 요소는 아래 그림에 나와 있습니다.

CPU는 가상 주소를 입력으로 제공받고, 물리적 주소를 출력(사용)해야 합니다. 모든 것이 페이지 단위로 작동하므로 주소의 하위 12비트(페이지 내 오프셋)는 변환되지 않고 그대로 최종 주소로 전달됩니다.

CPU는 변환을 위해 컨텍스트가 필요합니다. 모든 프로세스에는 항상 RAM에 상주하는 초기 구조가 있습니다. 32비트 시스템의 경우 페이지 디렉터리 포인터 테이블이라고 하며, 64비트 시스템의 경우 페이지 매핑 레벨 4(Intel 용어)라고 합니다. 이 초기 구조에서 페이지 디렉토리와 페이지 테이블을 포함한 다른 구조가 사용됩니다. 페이지 테이블 항목은 물리적 페이지 주소(유효한 비트가 설정된 경우)를 가리키는 항목입니다. 페이지가 페이지 파일로 이동되면 메모리 관리자는 해당 페이지 테이블 항목을 유효하지 않은 것으로 표시하여 다음에 CPU가 페이지를 만날 때 페이지 오류 예외가 발생합니다.

마지막으로 TLB(Translation Lookaside Buffer)는 최근 번역된 페이지의 캐시이므로 이러한 페이지에 액세스할 때 번역을 위해 여러 레이어를 탐색할 필요가 없습니다. 이 캐시는 상대적으로 작고 실용적인 관점에서 매우 중요합니다. 동일한 범위의 메모리 주소를 근접하게 사용하는 것은 TLB 캐시를 활용하는 데 유용합니다.

페이징 및 변환 색인 버퍼(TLB) 작업.

18.9.10 Windows 메모리

Windows는 메모리를 처리하기 위해 여러 API 세트를 제공하며 다음 그림은 사용 가능한 세트와 해당 종속성을 보여줍니다.

가장 낮은 수준은 메모리 관리자에 가장 가까운 가상 API로, 여러 가지 의미를 갖습니다.

  • 가상 메모리가 할 수 있는 모든 기능을 제공하는 가장 강력한 API입니다.

  • 항상 페이지 단위와 페이지 경계에서 작동합니다.

  • 고급 API를 사용합니다.

관련 가상 API는 다음과 같습니다.

cpp
LPVOID VirtualAlloc(LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect);
LPVOID VirtualAllocEx(HANDLE hProcess, LPVOID lpAddress, SIZE_T dwSize, DWORD flAllocationType, DWORD flProtect);
PVOID VirtualAllocFromApp(PVOID BaseAddress, SIZE_T Size, ULONG AllocationType, ULONG Protection);

BOOL VirtualFree(LPVOID lpAddress, SIZE_T dwSize, DWORD dwFreeType);
BOOL VirtualFreeEx(HANDLE hProcess, LPVOID lpAddress, SIZE_T dwSize, DWORD dwFreeType);

18.9.10.1 작업 세트

Working Set은 페이지 폴트 없이 접근할 수 있는 메모리를 나타냅니다. 물론 프로세스는 커밋된 모든 메모리가 작업 세트에 있기를 원하며 메모리 관리자는 한 프로세스의 요구 사항과 다른 모든 프로세스의 요구 사항 사이의 균형을 맞춰야 합니다. 오랫동안 액세스되지 않은 메모리가 프로세스의 작업 세트에서 제거될 수 있다고 해서 자동으로 폐기된다는 의미는 아닙니다. 메모리 관리자는 한때 프로세스 작업 세트의 일부였던 물리적 페이지를 필요한 것보다 오랫동안 RAM에 유지하기 위한 정교한 알고리즘을 가지고 있으므로 문제의 프로세스가 해당 메모리에 액세스하기로 결정하면 작업 세트에 들어가는 버그(소프트 페이지 오류라고 함)를 즉시 포착할 수 있습니다.

GetProcessMemoryInfo를 통해 프로세스의 현재 및 최고 작업 세트를 얻을 수 있습니다.

cpp
BOOL GetProcessMemoryInfo(HANDLE Process, PPROCESS_MEMORY_COUNTERS ppsmemCounters, DWORD cb);

프로세스에는 최소 및 최대 작업 세트가 있습니다. 기본적으로 이러한 제한은 소프트하므로 프로세스는 메모리가 충분할 경우 최대 작업 세트보다 더 많은 RAM을 사용할 수 있고, 메모리가 부족할 경우 최소 작업 세트보다 적은 RAM을 사용할 수 있습니다. GetProcessWorkingSetSize를 사용하여 다음 제한을 쿼리합니다.

cpp
BOOL GetProcessWorkingSetSize(HANDLE hProcess, PSIZE_T lpMinimumWorkingSetSize, PSIZE_T lpMaximumWorkingSetSize);

기타 작업 세트 관련 API:

cpp
BOOL SetProcessWorkingSetSize(HANDLE hProcess, SIZE_T dwMinimumWorkingSetSize, SIZE_T dwMaximumWorkingSetSize);
BOOL WINAPI EmptyWorkingSet(HANDLE hProcess);
BOOL SetProcessWorkingSetSizeEx(HANDLE hProcess, SIZE_T dwMinimumWorkingSetSize, SIZE_T dwMaximumWorkingSetSize, DWORD Flags);
BOOL GetProcessWorkingSetSizeEx(HANDLE hProcess, PSIZE_T lpMinimumWorkingSetSize, PSIZE_T lpMaximumWorkingSetSize, PDWORD Flags);

18.9.10.2 힙

VirtualAlloc 기능 세트는 메모리 관리자와 매우 가깝기 때문에 매우 강력합니다. 그러나 단점이 있습니다. 이러한 함수는 페이지 블록에서만 작동합니다. 10바이트가 할당되면 페이지가 반환됩니다. 10바이트를 더 할당하면 다른 페이지가 생성되는데, 이는 애플리케이션에서 흔히 발생하는 작은 할당을 관리하기에는 너무 낭비입니다. 이것이 힙이 작동하는 곳입니다.

힙 관리자는 작은 할당을 효율적으로 관리하는 방법을 아는 가상 API 위에 계층화된 구성 요소입니다. 이 컨텍스트에서 힙은 힙 관리자가 관리하는 메모리 블록이며 각 프로세스는 기본 프로세스 힙이라는 단일 힙으로 시작됩니다. GetProcessHeap을 사용하여 힙에 대한 핸들을 얻습니다.

cpp
HANDLE GetProcessHeap();

더 많은 힙을 생성할 수 있습니다. 힙의 경우 HeapAlloc을 사용하여 메모리를 할당(커밋)합니다.

cpp
LPVOID HeapAlloc(HANDLE hHeap, DWORD dwFlags, SIZE_T dwBytes);

기타 힙 관련 API:

cpp
BOOL HeapFree(HANDLE hHeap, DWORD dwFlags, LPVOID lpMem);
HANDLE HeapCreate(DWORD flOptions, SIZE_T dwInitialSize, SIZE_T dwMaximumSize);
BOOL HeapDestroy(HANDLE hHeap);

C/C++ 메모리 관리 기능(예: malloc, calloc, free, C++ new 및 delete 연산자 등)의 구현은 컴파일러에서 제공하는 라이브러리에 따라 다릅니다. C/C++ 런타임은 힙 함수를 사용하여 할당을 관리합니다. 다음은 명확성을 위해 일부 매크로와 지침을 제거한 malloc의 구현입니다(malloc.cpp에서).

python
extern "C" void* __cdecl malloc(size_t const size) 
{
#ifdef _DEBUG
    return _malloc_dbg(size, _NORMAL_BLOCK, nullptr, 0);
#else
    return _malloc_base(size);
#endif
}

malloc에는 두 가지 구현이 있습니다. 하나는 디버그 빌드용이고 다른 하나는 릴리스 빌드용입니다. 다음은 릴리스 빌드에서 발췌한 것입니다(malloc_base.cpp 파일에 있음).

cpp
extern "C" __declspec(noinline) void* __cdecl _malloc_base(size_t const size) 
{
    // Ensure that the requested size is not too large:
    _VALIDATE_RETURN_NOEXC(_HEAP_MAXREQ >= size, ENOMEM, nullptr);
    // Ensure we request an allocation of at least one byte:
    size_t const actual_size = size == 0 ? 1 : size;
    for (;;) 
    {
        void* const block = HeapAlloc(__acrt_heap, 0, actual_size);
        if (block)
            return block;
        
        //...code omitted...
}
    
extern "C" bool __cdecl __acrt_initialize_heap() 
{
    __acrt_heap = GetProcessHeap();
    if (__acrt_heap == nullptr)
        return false;
    
    return true;
}

사실 위 API는 VirtualAlloc의 빙산의 일각에 불과합니다. Windows는 또한 API에 다른 많은 기능을 제공합니다.

18.9.10.3 비균일 메모리 아키텍처(NUMA)

NUMA(Non-Uniform Memory Architecture) 시스템에는 일련의 프로세서와 메모리를 보유하는 노드 세트가 포함됩니다. 다음 그림은 이러한 시스템의 토폴로지 예를 보여줍니다.

위 그림은 각각 4개의 코어와 8개의 논리 프로세서가 있는 소켓을 보유하는 2개의 NUMA 노드가 있는 시스템의 예를 보여줍니다. NUMA 시스템은 모든 CPU가 모든 코드를 실행하고 모든 노드의 모든 메모리에 액세스할 수 있다는 점에서 여전히 대칭적입니다. 그러나 로컬 노드에서 메모리에 액세스하는 것이 다른 노드의 메모리에 액세스하는 것보다 훨씬 빠릅니다.

Windows는 NUMA 시스템의 토폴로지를 알고 있습니다. 이전에 스레드 스케줄링에 대해 설명한 것처럼 스케줄러는 이 정보를 최대한 활용하고 스레드 스택이 해당 노드의 물리적 메모리에 있는 CPU에서 스레드를 예약하려고 시도합니다. NUMA 시스템은 일반적으로 여러 소켓이 존재하는 서버 시스템에 사용됩니다.

18.9.10.4 메모리 매핑 파일

파일 매핑 개체는 Windows의 모든 곳에 있습니다. 이미지 파일(EXE 또는 DLL)이 로드되면 메모리 매핑 파일을 사용하여 메모리에 매핑됩니다. 이 매핑을 통해 표준 포인터를 통해 메모리에 액세스하고 기본 파일에 간접적으로 액세스합니다. 코드가 이미지 내에서 실행되어야 하는 경우 초기 액세스로 인해 페이지 오류 예외가 발생하며, 메모리 관리자는 이 메모리 매핑을 위해 적절한 페이지 테이블을 복구하기 전에 파일에서 데이터를 읽고 이를 실제 메모리에 배치하여 처리합니다. 이때 호출 스레드는 코드/데이터에 액세스할 수 있습니다. 이는 애플리케이션에 투명합니다.

일부 코드는 파일에서 일부 데이터를 검색해야 하며 검색은 파일 내에서 앞뒤로 이동해야 합니다. I/O API의 경우 ReadFile(버퍼가 미리 할당됨) 및 SetFilePointer(Ex)에 대한 여러 호출이 포함되어 기껏해야 불편합니다. 반면, 파일에 대한 "포인터"를 사용할 수 있으면 파일 작업을 이동하고 수행하는 것이 훨씬 쉽습니다. 버퍼를 할당할 필요가 없고 파일 읽기 호출이 필요하지 않으며 모든 파일 포인터 변경 사항은 단순히 포인터 연산으로 변환됩니다. memcpy, memset 등과 같은 다른 모든 일반적인 메모리 기능도 메모리 매핑 파일에서 작동합니다.

메모리 매핑된 파일과 관련된 일반적인 API는 다음과 같습니다.

cpp
HANDLE CreateFileMapping(HANDLE hFile, LPSECURITY_ATTRIBUTES lpFileMappingAttributes, DWORD flProtect, DWORD dwMaximumSizeHigh, DWORD dwMaximumSizeLow, LPCTSTR lpName);
LPVOID MapViewOfFile(HANDLE hFileMappingObject, DWORD dwDesiredAccess, DWORD dwFileOffsetHigh, DWORD dwFileOffsetLow, SIZE_T dwNumberOfBytesToMap);
BOOL UnmapViewOfFile(_In_ LPCVOID lpBaseAddress);

18.9.10.5 공유 메모리

프로세스는 서로 격리되어 있으므로 각각 고유한 주소 공간, 고유한 핸들 테이블 등을 갖습니다. 대부분의 경우 이는 정확히 우리가 원하는 것입니다. 그러나 어떤 경우에는 프로세스 간에 데이터를 어떻게든 공유해야 합니다. Windows는 COM(구성 요소 개체 모델), Windows 메시지, 소켓, 파이프, 메일 슬롯, RPC(원격 프로시저 호출), 클립보드, DDE(동적 데이터 교환) 등을 포함하여 IPC(프로세스 간 통신)를 위한 다양한 메커니즘을 제공합니다. 각 방법에는 장점과 단점이 있지만 위의 모든 방법의 공통 주제는 메모리를 한 프로세스에서 다른 프로세스로 복사해야 한다는 것입니다.

메모리 매핑 파일은 IPC의 또 다른 메커니즘이며 복사가 없기 때문에 가장 빠릅니다. 실제로 일부 다른 IPC 메커니즘은 동일한 시스템의 프로세스 간에 통신할 때 메모리 매핑 파일을 사용합니다. 한 프로세스가 공유 메모리에 데이터를 쓰면 동일한 파일 매핑 개체에 대한 핸들이 있는 다른 모든 프로세스는 즉시 메모리를 볼 수 있으며 각 프로세스는 동일한 메모리를 자체 주소 공간에 매핑하므로 복사가 이루어지지 않습니다.

공유 메모리는 동일한 파일 매핑 개체에 액세스하는 여러 프로세스를 기반으로 합니다. 개체는 세 가지 방법 중 하나로 공유할 수 있으며, 가장 간단한 방법은 파일 매핑 개체의 이름을 사용하는 것입니다. 공유 메모리 자체는 특정 파일(CreateFileMapping의 유효한 파일 핸들)에 의해 백업될 수 있는데, 이 경우 파일 매핑 객체가 소멸된 후에도 데이터가 계속 사용 가능하며, 페이징 파일에 의해 백업될 수 있는데, 이 경우 파일 매핑 객체가 소멸되면 데이터가 폐기됩니다. 두 옵션 모두 기본적으로 동일한 방식으로 작동합니다.

파일 매핑 개체는 데이터 일관성과 관련된 몇 가지 보장을 제공합니다.

  • 동일한 데이터/파일의 여러 보기(여러 프로세스에서도)는 서로 다른 보기가 동일한 물리적 메모리에 매핑되므로 동기화가 보장됩니다. 유일한 예외는 네트워크를 통해 원격 파일을 매핑하는 경우입니다. 이 경우 다른 컴퓨터의 보기는 항상 동기화되지 않을 수 있지만 동일한 컴퓨터의 보기는 계속 동기화됩니다.

  • 동일한 파일을 매핑하는 여러 파일 매핑 개체는 동기화가 보장되지 않습니다. 일반적으로 두 개 이상의 파일 매핑 개체를 사용하여 동일한 파일을 매핑하는 것은 좋지 않습니다. (적어도 쓰기를 의도한 경우) 파일에 대한 다른 접근이 불가능하도록 단독 접근으로 파일을 여는 것이 더 좋습니다.

  • 파일이 파일 매핑 개체에 의해 매핑되고 일반 I/O(파일 읽기, 파일 쓰기 등)를 위해 동시에 열리는 경우 I/O 작업에 대한 변경 사항은 일반적으로 파일의 동일한 위치에 매핑된 보기에 즉시 반영되지 않습니다. 이런 상황은 피해야 합니다.

18.10 동적 링크 라이브러리

18.10.1 DLL 개요

동적 링크 라이브러리(DLL)는 Windows NT의 필수적인 부분입니다. DLL의 존재 이면에 있는 주요 동기는 DLL이 프로세스 간에 쉽게 공유될 수 있기 때문에 DLL의 단일 복사본이 RAM에 있고 DLL 코드가 이를 필요로 하는 모든 프로세스에서 공유될 수 있다는 것입니다. 초기에는 RAM이 지금보다 훨씬 작았기 때문에 메모리 절약이 매우 중요했습니다. 오늘날에도 일반적인 프로세스는 수십 개의 DLL을 사용하기 때문에 메모리 절약은 매우 중요합니다.

DLL은 코드, 데이터 및 리소스 중 하나 이상을 포함할 수 있는 PE(이식 가능) 파일입니다. 각 사용자 모드 프로세스는 kernel32.dll, user32.dll, gdi32.dll 및 advapi32.dll과 같은 하위 시스템 dll을 사용하여 문서화된 Windows API를 구현합니다. 물론 Ntdll.Dll은 기본 응용 프로그램을 포함한 모든 사용자 모드 프로세스에 필요합니다.

DLL은 함수, 전역 변수 및 메뉴, 비트맵, 아이콘과 같은 리소스를 포함할 수 있는 라이브러리입니다. 특정 함수(및 유형)는 DLL을 통해 내보낼 수 있으므로 해당 DLL을 로드하는 다른 DLL이나 실행 파일이 이를 직접 사용할 수 있습니다. DLL은 프로세스가 시작될 때 암시적으로 프로세스에 로드될 수 있고, 응용 프로그램이 LoadLibrary 또는 LoadLibraryEx 함수를 호출할 때 명시적으로 로드될 수 있습니다.

18.10.2 명시적 로딩

DLL에 명시적으로 연결하면 DLL이 로드되고 언로드되는 시기를 더 효과적으로 제어할 수 있습니다. 또한 DLL 로드에 실패해도 프로세스가 중단되지 않으므로 애플리케이션이 오류를 처리하고 계속할 수 있습니다. 명시적 DLL 링크의 일반적인 용도는 언어 관련 리소스를 로드하는 것입니다. 예를 들어 응용 프로그램은 현재 시스템 로케일의 리소스가 포함된 DLL을 로드하려고 시도할 수 있으며 찾을 수 없는 경우 항상 응용 프로그램 설치의 일부로 제공되는 기본 리소스 DLL을 로드할 수 있습니다. 명시적 링크의 경우 가져오기 라이브러리가 사용되지 않으므로 로더는 DLL(존재하지 않을 수 있음)을 로드하려고 시도하지 않습니다. 이는 또한 링커가 "해결되지 않은 외부" 오류로 인해 실패하므로 내보낸 기호 선언을 가져오는 데 #include를 사용할 수 없음을 의미합니다. 그러한 DLL을 어떻게 사용할 수 있습니까?

첫 번째 단계는 일반적으로 필요할 때 런타임에 로드하는 것입니다. LoadLibrary의 작업은 다음과 같습니다.

cpp
HMODULE LoadLibrary(LPCTSTR lpLibFileName);

LoadLibrary는 파일 이름이나 전체 경로만 허용합니다. 파일 이름만 지정하면 암시적으로 로드된 것과 동일한 순서로 DLL이 검색됩니다. 전체 경로를 지정하면 해당 파일만 로드됩니다. 실제 검색이 시작되기 전에 로더는 동일한 이름의 모듈이 프로세스 주소 공간에 이미 로드되어 있는지 확인합니다. 그렇다면 검색이 수행되지 않고 기존 DLL에 대한 핸들이 반환됩니다. 예를 들어 SimpleDell.Dll이 이미(모든 경로에서) 로드되었고 LoadLibrary가 SimpleDll.Dll이라는 파일을 로드하기 위해 호출된 경우(모든 경로에서 또는 경로 없이) 다른 Dll은 로드되지 않습니다.

DLL이 성공적으로 로드된 후 GetProcAddress를 사용하여 DLL에서 내보낸 함수에 액세스할 수 있습니다.

cpp
FARPROC GetProcAddress(HMODULE hModule, LPCSTR lpProcName);

이 함수는 DLL에서 내보낸 기호의 주소를 반환합니다. 두 번째 매개변수는 기호의 이름입니다. 이름은 ASCII여야 합니다. 반환 값은 "far"와 "near"가 다른 것을 의미하는 일반 FARPROC입니다. 기호가 존재하지 않는 경우(또는 내보내지 않는 경우, 이는 동일함) GetProcAddress는 NULL을 반환합니다. 예를 들어:

cpp
// dll의 내에서 개 함수 선언
__declspec(dllexport) bool IsPrime(int n);

// 로드 dll,추출/내보내기(Export)의 함수 ,호출(Call)。
auto hPrimesLib = ::LoadLibrary(L"SimpleDll.dll");
if (hPrimesLib) 
{
    // DLL found
    using PIsPrime = bool (*)(int);
    auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "IsPrime");
    if (IsPrime) 
    {
        bool test = IsPrime(17);
        printf("%d\n", (int)test);
    }
}

이 코드는 상대적으로 단순해 보입니다. DLL이 로드됩니다. 불행하게도 GetLastError가 127을 반환하면서 GetProcAddress 호출이 실패합니다(지정된 프로세스를 찾을 수 없습니다). 분명히 GetProcAddress는 내보낸 함수를 찾을 수 없습니다. 왜?

그 이유는 함수 이름과 관련이 있습니다. Dumpbin을 사용하여 SimpleDll.Dll에 대한 정보를 확인하는 경우:

cpp
0 000111F9 ?IsPrime@@YA_NH@Z = @ILT+500(?IsPrime@@YA_NH@Z)

이유가 발견되었습니다. 링커가 사용할 함수 이름을 "망쳤습니다": IsPrime@@YA_NH@Z. 그 이유는 IsPrime이 C++에서 충분히 고유하지 않다는 사실과 관련이 있습니다. iPrime 함수는 클래스 A, 클래스 B 및 전역 함수일 수도 있고 특정 네임스페이스 C의 일부일 수도 있습니다. 또한 C++ 함수 오버로드로 인해 동일한 범위에 IsPrime이라는 함수가 여러 개 있을 수 있습니다. 따라서 링커는 이러한 고유한 속성이 포함된 이상한 이름을 함수에 부여합니다. 이전 코드 예제에서 이 잘못된 이름을 다음과 같이 바꿀 수 있습니다.

cpp
auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "?IsPrime@@YA_NH@Z");

당신은 그것이 효과가 있다는 것을 알게 될 것입니다! 그러나 이는 매우 지루하고 실용적이지 않습니다. 따라서 잘못된 이름을 찾아서 올바르게 만드는 방법을 찾아야 합니다. 일반적인 방법은 내보낸 모든 함수를 C 스타일 함수로 변환하는 것입니다. C에서는 함수 오버로딩이나 클래스를 지원하지 않으므로 링커에서 복잡한 수정을 수행할 필요가 없습니다. 함수를 C로 내보내는 한 가지 방법은 다음과 같습니다.

cpp
extern "C" __declspec(dllexport) bool IsPrime(int n);

C 파일을 컴파일하는 경우 위의 내용이 기본값이 됩니다.

이 변경으로 IsPrime 함수에 대한 포인터를 가져오는 것이 단순화될 수 있습니다.

cpp
auto IsPrime = (PIsPrime)::GetProcAddress(hPrimesLib, "IsPrime");

그러나 함수를 C 스타일로 변환하는 이 방식은 클래스의 멤버 함수에 사용할 수 없습니다. 이것이 바로 GetProcAddress를 사용하여 C++ 함수에 액세스하는 것이 실용적이지 않고 LoadLibrary/GetProcAddress에 대한 대부분의 DLL이 C 스타일 함수만 노출하는 이유입니다.

DLL이 더 이상 필요하지 않으면 다음 API를 사용하여 해제할 수 있습니다.

cpp
BOOL FreeLibrary(HMODULE hLibModule);

시스템은 로드된 각 DLL에 대해 프로세스별 카운터를 유지 관리합니다. LoadLibrary가 동일한 DLL에서 여러 번 호출되는 경우 실제로 프로세스 주소 공간에서 DLL을 언로드하려면 동일한 수의 FreeLibrary 호출이 필요합니다. 로드된 DLL에 대한 핸들이 필요한 경우 GetModuleHandle을 사용하여 검색할 수 있습니다.

cpp
HMODULE GetModuleHandle(LPCTSTR lpModuleName);

18.10.3 호출 규칙

호출 규칙은 함수 매개변수가 함수에 전달되는 방법과 스택에 전달된 경우 매개변수 정리를 담당하는 사람을 나타냅니다. x64의 경우 호출 규칙이 하나만 있습니다. x86에는 여러 가지가 있으며 가장 일반적인 것은 표준 호출 규칙 stdcall과 C 호출 규칙 cdecl입니다. stdcall과 cdecl은 모두 스택을 사용하여 매개변수를 전달하고 오른쪽에서 왼쪽으로 푸시합니다. 이들 사이의 주요 차이점은 stdcall의 경우 호출 수신자(함수 본문 자체)가 스택 정리를 담당하는 반면, cdecl의 경우 호출자가 스택 정리를 담당한다는 것입니다.

stdcall은 스택 정리 코드 중 하나만 (함수 본문의 일부로) 표시되므로 크기가 더 작다는 장점이 있습니다. cdecl을 사용하면 함수를 호출할 때마다 스택에서 인수를 지우는 명령이 나와야 합니다. cdecl 함수의 장점은 호출자만이 전달된 인수 수를 알 수 있으므로 가변 개수의 인수(C/C++에서 지정됨)를 허용할 수 있다는 것입니다.

Visual C++의 사용자 모드 프로젝트에 사용되는 기본 호출 규칙은 cdecl입니다. 호출 규칙은 반환 유형과 함수 이름 사이에 적절한 키워드를 배치하여 지정됩니다. 이를 위해 Microsoft 컴파일러는 __cdecl 및 __stdcall 키워드를 다시 인식합니다. 사용된 키워드는 구현 시에도 지정되어야 합니다. 다음은 stdcall을 사용하도록 IsPrime을 설정하는 예입니다.

cpp
extern "C" __declspec(dllexport) bool __stdcall IsPrime(int n);

이는 또한 GetProcAddress와 함께 사용할 함수 포인터를 정의할 때 올바른 호출 규칙도 지정해야 함을 의미합니다. 그렇지 않으면 런타임 오류나 스택 손상이 발생합니다.

python
using PIsPrime = bool (__stdcall *)(int);
// or
typedef bool(__stdcall* PIsPrime)(int);

__stdcall은 대부분의 Windows API에서 사용되는 호출 규칙으로, 일반적으로 정확히 동일한 의미를 갖는 WINAPI, APIENTRY, PASCAL 및 CALLBACK 매크로 중 하나를 사용합니다.

18.10.4 DllMain 함수

DLL에는 일반적으로 DllMain이라고 하는 진입점이 있을 수 있지만 반드시 있어야 하는 것은 아닙니다. 이 진입점에는 다음과 같은 프로토타입이 있어야 합니다.

cpp
BOOL WINAPI DllMain(HINSTANCE hInsdDll, DWROD reason, PVOID reserved);

hInstance 매개변수는 DLL이 프로세스에 로드되는 가상 주소이며, DLL이 명시적으로 로드되는 경우 LoadLibrary에서 반환되는 값과 동일합니다. 이유 매개변수는 DllMain을 호출하는 이유를 나타내며 해당 값은 다음 표에 설명되어 있습니다.

이유 값 설명

DLL_PROCESS_ATTACH DLL이 프로세스에 연결될 때 호출됩니다.

DLL_PROCESS_DETACH 프로세스에서 DLL을 언로드하기 전에 호출됩니다.

DLL_THREAD_ATTACH 프로세스에서 새 스레드가 생성될 때 호출됩니다.

DLL_THREAD_DETACH 스레드가 프로세스를 종료하기 전에 호출됩니다.

18.10.5 DLL 주입

어떤 경우에는 DLL을 다른 프로세스에 주입해야 합니다. DLL 주입은 DLL이 대상 프로세스의 컨텍스트에서 코드를 실행할 수 있도록 하는 방식으로 다른 프로세스가 특정 DLL을 로드하도록 하는 것을 의미합니다. 이 기능에는 다양한 용도가 있지만 기본적으로는 모두 기본적으로 대상의 프로세스 내 작업을 사용자 정의하거나 가로채는 형태로 귀결됩니다. 다음은 몇 가지 구체적인 예입니다.

  • 맬웨어 방지 솔루션 및 기타 응용 프로그램은 대상 프로세스에 API 기능을 연결하려고 할 수 있습니다.

  • 창이나 컨트롤을 서브클래싱하여 창을 사용자 정의하고 UI의 동작을 변경할 수 있는 기능입니다.

  • 대상 프로세스의 일부가 되면 해당 프로세스의 모든 항목에 무제한으로 액세스할 수 있습니다. 결함이 있는 DLL을 찾기 위해 애플리케이션 동작을 모니터링하는 것과 같은 일부 기능은 좋지만 일부 기능은 좋지 않습니다.

18.10.5.1 원격 스레드 주입

필요한 DLL을 로드하는 대상 프로세스에 스레드를 생성하여 DLL을 주입하는 것은 아마도 가장 잘 알려져 있고 간단한 기술일 것입니다. 아이디어는 주입될 DLL의 경로와 함께 LoadLibrary 함수를 호출하는 대상 프로세스에 스레드를 생성하는 것입니다. 대상 프로세스에 코드를 실행하는 예제 코드는 다음과 같습니다.

cpp
int main(int argc, const char* argv[]) 
{
    // 파라미터
    if (argc < 3) 
    {
        printf("Usage: injector <pid> <dllpath>\n");
        return 0;
    }

    // ID 및 의 DLL, 의
    HANDLE hProcess = ::OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD, FALSE, atoi(argv[1]));
    if (!hProcess)
        return Error("Failed to open process");

    // 로드 의 DLL, 필수: 내에서 ,이므로 는 실행(Execute)LoadLibrary의 . 로써 을(를) 활용하여 VirtualAllocEx함수 .
    void* buffer = ::VirtualAllocEx(hProcess, nullptr, 1 << 12, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
    if (!buffer)
        return Error("Failed to allocate buffer in target process");

    // 을(를) 활용하여 WriteProcessMemoryDLL까지 할당(Allocate)의 버퍼
    if (!::WriteProcessMemory(hProcess, buffer, argv[2], ::strlen(argv[2]) + 1, nullptr))
        return Error("Failed to write to target process");
    
    // 생성(Create)스레드(Thread)
    DWORD tid;
    HANDLE hThread = ::CreateRemoteThread(hProcess, nullptr, 0,
        (LPTHREAD_START_ROUTINE)::GetProcAddress(::GetModuleHandle(L"kernel32"), "LoadLibraryA"),
        buffer, 0, &tid);
    if (!hThread)
        return Error("Failed to create remote thread");
    
    // 스레드(Thread)탈출/종료(Exit)
    printf("Thread %u created successfully!\n", tid);
    if (WAIT_OBJECT_0 == ::WaitForSingleObject(hThread, 5000))
        printf("Thread exited.\n");
    else
        printf("Thread still hanging around...\n");
    
    // 해제(Release) 및
    ::VirtualFreeEx(hProcess, buffer, 0, MEM_RELEASE);
    ::CloseHandle(hThread);
    ::CloseHandle(hProcess);
}

위 코드에서는 로딩 규칙이 호출자가 아닌 대상 프로세스의 관점에서 이루어지기 때문에 DLL의 전체 경로를 지정해야 합니다. 주입된 DLL의 DllMain은 간단한 메시지 상자를 표시합니다.

cpp
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, PVOID lpReserved) 
{
    switch (reason) 
    {
        case DLL_PROCESS_ATTACH:
            wchar_t text[128];
            ::StringCchPrintf(text, _countof(text), L"Injected into process %u", ::GetCurrentProcessId());
            ::MessageBox(nullptr, text, L"Injected.Dll", MB_OK);
        break;
    }
    return TRUE;
}

18.10.5.2 윈도우 후크

Windows 후크는 SetWindowsHookEx와 같은 API를 통해 사용할 수 있는 사용자 인터페이스 관련 후크 세트를 나타냅니다.

cpp
HHOOK SetWindowsHookEx(int idHook, HOOKPROC lpfn, HINSTANCE hmod, DWORD dwThreadId);

lpfn에서 제공하는 후크 기능에는 다음과 같은 프로토타입이 있습니다.

python
typedef LRESULT (CALLBACK* HOOKPROC)(int code, WPARAM wParam, LPARAM lParam);

샘플 코드:

cpp
int main() 
{
    DWORD tid = FindMainNotepadThread();
    if (tid == 0)
        return Error("Failed to locate Notepad");
    
    auto hDll = ::LoadLibrary(L"HookDll");
    if (!hDll)
        return Error("Failed to locate Dll\n");
    
    using PSetNotify = void (WINAPI*)(DWORD, HHOOK);
    auto setNotify = (PSetNotify)::GetProcAddress(hDll, "SetNotificationThread");
    if (!setNotify)
        return Error("Failed to locate SetNotificationThread function in DLL");
    
    auto hookFunc = (HOOKPROC)::GetProcAddress(hDll, "HookFunction");
    if (!hookFunc)
        return Error("Failed to locate HookFunction function in DLL");
    
    // 설정(Set)。
    auto hHook = ::SetWindowsHookEx(WH_GETMESSAGE, hookFunc, hDll, tid);
    if (!hHook)
        return Error("Failed to install hook");
    
    (...)
}

DLL(또는 EXE) 내에서 변수를 공유하는 기술. 주입된 애플리케이션은 자체 프로세스의 컨텍스트에서 SetNotificationThread를 호출하지만 함수는 공유 변수에 정보를 기록하므로 이러한 변수는 동일한 DLL을 사용하는 모든 프로세스에서 사용할 수 있습니다.

18.10.5.3 API 후크

API 후킹은 매개변수를 검사하고 동작을 변경할 수 있도록 Windows API(또는 더 일반적으로는 외부 기능)를 가로채는 행위를 의미합니다. 이는 일반적으로 각 프로세스(또는 대부분의 프로세스)에 자체 DLL을 삽입하고 VirtualAllocEx 및 CreateRemoteThread와 같이 관심 있는 특정 기능을 연결하여 DLL에서 제공하는 대체 구현으로 리디렉션하는 맬웨어 방지 솔루션에서 가장 먼저 사용하는 매우 강력한 기술입니다. 이 구현에서는 호출자에게 오류 코드를 반환하거나 호출을 원래 함수에 전달하기 전에 매개 변수를 확인하고 필요한 작업을 수행할 수 있습니다.

  • IAT 후크

IAT(Import Address Table) 후크는 아마도 가장 간단한 함수 후크 방법일 것이며 설정이 상대적으로 간단하고 플랫폼별 코드가 필요하지 않습니다. 각 PE 이미지에는 자신이 의존하는 DLL과 DLL에서 사용하는 기능을 나열하는 가져오기 테이블이 있습니다. dumpbin 또는 그래픽 도구를 사용하여 PE 파일을 검사하여 이러한 가져오기를 확인하세요. notepad.exe의 모듈 개요는 다음과 같습니다.

cpp
D:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.29.30133\bin\Hostx64\arm>dumpbin /imports c:\Windows\System32\notepad.exe

Microsoft (R) COFF/PE Dumper Version 14.29.30133.0
Copyright (C) Microsoft Corporation.  All rights reserved.

Dump of file c:\Windows\System32\notepad.exe

File Type: EXECUTABLE IMAGE

  Section contains the following imports:

    KERNEL32.dll
             1400268B8 Import Address Table
             14002D3D8 Import Name Table
                     0 time date stamp
                     0 Index of first forwarder reference

                         2B8 GetProcAddress
                          DC CreateMutexExW
                           1 AcquireSRWLockShared
                         114 DeleteCriticalSection
                         221 GetCurrentProcessId
                         2BE GetProcessHeap
                         281 GetModuleHandleW
                         10A DebugBreak
                         387 IsDebuggerPresent
                         342 GlobalFree
(...)
 
     GDI32.dll
             140026800 Import Address Table
             14002D320 Import Name Table
                     0 time date stamp
                     0 Index of first forwarder reference

                          34 CreateDCW
                         39F StartPage
                         39D StartDocW
                         366 SetAbortProc
                         180 DeleteDC
                         18E EndDoc
(...)

    USER32.dll
             140026B50 Import Address Table
             14002D670 Import Name Table
                     0 time date stamp
                     0 Index of first forwarder reference

                         2AF PostMessageW
                         28C MessageBoxW
                         177 GetMenu
                          43 CheckMenuItem
                         1C2 GetSubMenu
                          E9 EnableMenuItem
                         38D ShowWindow
                         142 GetDC
                         2FC ReleaseDC
(...)

  Summary
        3000 .data
        1000 .didat
        2000 .pdata
        A000 .rdata
        1000 .reloc
        1000 .rsrc
       25000 .text

이러한 가져온 함수가 호출되는 방식은 로더(NtDll.Dll)가 런타임 시 함수를 매핑한 후 이러한 함수의 최종 주소가 포함된 가져오기 주소 테이블을 통해 호출됩니다. DLL이 기본 주소에 로드되지 않을 수 있으므로 이러한 주소는 미리 알려지지 않았습니다.

IAT 후크는 모든 호출이 간접적이라는 점을 활용하고 런타임 시 단순히 테이블의 함수 주소를 대체 함수를 가리키도록 바꾸는 동시에 필요할 때 구현을 호출할 수 있도록 원래 주소를 저장합니다. 이 후킹은 현재 프로세스에서 수행되거나 다른 프로세스의 컨텍스트에서 DLL 주입과 결합될 수 있습니다.

각 모듈에는 자체 IAT가 있으므로 모든 프로세스 모듈에서 후크할 기능을 검색해야 합니다. 예를 들어 메모장은 CreateFileW.exe 모듈 자체를 호출할 수 있지만 ComCtl32.dll은 파일 열기 대화 상자가 호출될 때 이를 호출할 수도 있습니다. 메모장 호출에만 관심이 있다면 해당 IAT에만 연결해야 합니다. 그렇지 않으면 로드된 모든 모듈을 검색하고 CreateFileW에 대한 IAT 항목을 바꿔야 합니다.

다음 샘플 코드는 User32.Dll에서 GetSysColor API를 후크하고 애플리케이션의 UI 코드를 건드리지 않고 애플리케이션의 일부 색상을 변경합니다.

cpp
void HookFunctions() 
{
    auto hUser32 = ::GetModuleHandle(L"user32");
    // save original functions
    GetSysColorOrg = (decltype(GetSysColorOrg))::GetProcAddress(hUser32, "GetSysColor");

    // IAT함수 을(를) 활용하여
    auto count = IATHelper::HookAllModules("user32.dll", GetSysColorOrg, GetSysColorHooked);
    ATLTRACE(L"Hooked %d calls to GetSysColor\n");
}

18.10.5.4 "회전 교차로" 후크

함수를 연결하는 또 다른 일반적인 방법은 다음 단계를 수행하는 것입니다.

  • 원래 함수의 주소를 찾아 저장합니다.

  • 코드의 처음 몇 바이트를 JMP 어셈블리 지침으로 대체하여 이전 코드를 저장합니다.

  • JMP 명령어는 후크 기능을 호출합니다.

  • 원래 코드로 전화를 걸려면 1단계에서 저장한 주소를 이용하세요.

  • 언후크 시 수정된 바이트를 복원합니다.

이 방식은 IAT를 통해 호출되는지 여부에 관계없이 실제 함수 코드가 수정되므로 IAT Hook보다 강력합니다. 그러나 이 접근 방식에는 두 가지 단점이 있습니다.

  • 대체된 코드는 플랫폼에 따라 다릅니다. x86, x64, ARM 및 ARM64용 코드는 다르기 때문에 올바르게 사용하기가 더 어렵습니다.

  • 위의 단계는 자동으로 완료되어야 합니다. 프로세스에 어셈블리 바이트가 교체될 때 후크 함수를 호출하는 다른 스레드가 있을 수 있으며 이로 인해 프로그램이 중단될 수 있습니다.

이러한 종류의 후크를 구현하는 것은 어렵고 위의 동기화 문제는 말할 것도 없고 CPU 명령 및 호출 규칙에 대한 복잡한 지식이 필요합니다. 이 기능을 제공하는 여러 오픈 소스 및 무료 라이브러리가 있습니다. 그 중 하나는 Microsoft의 "Detours"이지만 MinHook 및 EasyHook과 같은 다른 라이브러리도 있습니다. 이러한 후크가 필요한 경우 기존 라이브러리를 우선적으로 사용하세요. 다음은 우회 후크 사용의 예입니다.

cpp
#include <detours.h>
bool HookFunctions() 
{
    DetourTransactionBegin();
    DetourUpdateThread(GetCurrentThread());
    DetourAttach((PVOID*)&GetWindowTextOrg, GetWindowTextHooked);
    DetourAttach((PVOID*)&GetWindowTextLengthOrg, GetWindowTextLengthHooked);
    auto error = DetourTransactionCommit();
    return error == ERROR_SUCCESS;
}

우회는 원자적으로 수행되는 커밋된 작업 집합인 트랜잭션 개념으로 작동합니다. 후크하기 전에 GetProcAddress를 사용하거나 포인터 정의를 사용하여 수행할 수 있는 원래 함수를 저장해야 합니다.

cpp
decltype(::GetWindowTextW)* GetWindowTextOrg = ::GetWindowTextW;
decltype(::GetWindowTextLengthW)* GetWindowTextLengthOrg = ::GetWindowTextLengthW;

18.10.6 DLL 기본 주소

모든 DLL에는 PE 헤더의 일부인 기본 로드(기본) 주소가 있으며 Visual Studio에서 프로젝트 속성을 사용하여 이를 지정할 수도 있습니다(아래 이미지).

기본적으로는 아무것도 없으므로 Visual Studio에서는 일부 기본값을 사용합니다. 32비트 DLL의 경우 이 값은 0x10000000입니다. 64비트 DLL의 경우 0x180000000입니다. 이는 PE 헤더 정보에서 덤프하여 확인할 수 있습니다.

cpp
dumpbin /headers HookDll_x64.dll
...
    OPTIONAL HEADER VALUES
        20B magic # (PE32+)
...
        112FD entry point (00000001800112FD) @ILT+760(_DllMainCRTStartup)
            1000 base of code
        180000000 image base (0000000180000000 to 0000000180025FFF)
...

dumpbin /headers HookDll_x86.dll
...
OPTIONAL HEADER VALUES
    10B magic # (PE32)
...
    111B8 entry point (100111B8) @ILT+435(__DllMainCRTStartup@12)
        1000 base of code
        1000 base of data
    10000000 image base (10000000 to 1001FFFF)
...

18.10.7 지연 로딩 DLL

우리는 DLL에 연결하는 두 가지 주요 방법, 즉 LIB 파일을 사용한 암시적 연결(가장 간단하고 편리함)과 동적 연결(명시적으로 DLL을 로드하고 사용할 함수 찾기)을 살펴보았습니다. 세 번째 방법인 정적 링크와 동적 링크 사이의 "중간 지점"인 지연-로드 DLL이 있다는 것이 밝혀졌습니다.

지연 로딩에는 두 가지 이점이 있습니다. 정적 연결이 편리하고 필요할 때만 DLL을 동적으로 로딩할 수 있다는 것입니다. 지연 로딩 DLL을 사용하려면 실행 가능한 DLL이든 다른 DLL이든 관계없이 이러한 DLL을 사용하는 모듈을 일부 변경해야 합니다. 지연 로드되어야 하는 DLL은 입력 탭의 링커 옵션에 추가됩니다(아래 이미지).

지연 로드 DLL의 동적 언로드를 지원하려면 "고급 링커" 탭에 이 옵션("지연 로드 DLL 언로드")을 추가하세요. 남은 것은 DLL의 가져오기 라이브러리(LIB) 파일을 연결하고 암시적으로 연결된 DLL과 마찬가지로 내보낸 기능을 사용하는 것입니다. 지연 로딩 DLL의 예는 다음과 같습니다.

cpp
#include "..\SimpleDll\Simple.h"
#include <delayimp.h>

bool IsLoaded() 
{
    auto hModule = ::GetModuleHandle(L"simpledll");
    printf("SimpleDll loaded: %s\n", hModule ? "Yes" : "No");
    return hModule != nullptr;
}

int main() 
{
    IsLoaded();
    
    bool prime = IsPrime(17);
    IsLoaded();

    printf("17 is prime? %s\n", prime ? "Yes" : "No");
    
    // dll
    __FUnloadDelayLoadedDLL2("SimpleDll.dll");
    
    IsLoaded();
    prime = IsPrime(1234567);
    IsLoaded();
    
    return 0;
}

출력 결과:

cpp
SimpleDll loaded: No
SimpleDll loaded: Yes
17 is prime? Yes
SimpleDll loaded: No
SimpleDll loaded: Yes

18.11 파일 및 I/O

18.11.1 파일 및 I/O 개요

컴퓨터는 다양한 장치를 작동할 수 있으며 일반적인 유형에는 저장 장치(디스크, 테이프), 전송 장치(네트워크 카드, 모뎀) 및 인간-컴퓨터 인터페이스 장치(스크린, 키보드, 마우스)가 포함됩니다. 장치는 케이블이나 무선을 통해 신호를 보내 컴퓨터 시스템과 통신하고, 포트(예: 직렬 포트)라는 연결 지점을 통해 기계와 통신합니다. 하나 이상의 장치가 공통 전선 세트를 사용하는 경우 연결을 버스라고 합니다.

장치 A의 케이블이 장치 B에 연결되어 있고, 장치 B의 케이블이 장치 C에 연결되어 있으며, 장치 C가 컴퓨터의 포트에 연결되어 있는 경우 이러한 배열을 데이지 체인이라고 하며 종종 버스로 사용됩니다. 일반적인 PC 버스 구조가 그림에 나와 있습니다. PCI 버스(공통 PC 시스템 버스)는 프로세서 메모리 하위 시스템을 빠른 장치에 연결하고 확장 버스는 키보드, 직렬 및 USB 포트와 같은 상대적으로 느린 장치를 연결합니다.

아래 이미지의 오른쪽 상단에는 SCSI 컨트롤러에 연결된 SCSI(Small Computer System Interface) 버스에 4개의 디스크가 함께 연결되어 있습니다. 주요 컴퓨터 구성 요소를 상호 연결하는 데 사용되는 다른 일반적인 버스로는 초당 최대 16GB의 처리량을 제공하는 PCI Express(PCIe)와 초당 최대 25GB의 처리량을 제공하는 Hyper Transport가 있습니다.

컴퓨터 시스템에는 네트워크 카드, 그래픽 어댑터, 디스크 컨트롤러, DVD-ROM 컨트롤러, 직렬 포트, 범용 직렬 버스, 사운드 카드 등 여러 I/O 장치와 해당 컨트롤러가 포함되어 있습니다. 컨트롤러는 포트, 버스 또는 장치를 작동할 수 있는 전자 장치의 모음입니다. 간단한 장치 컨트롤러의 예로는 직렬 포트 전선의 신호를 제어하는 ​​컴퓨터의 마이크로 컨트롤러인 직렬 포트 컨트롤러가 있습니다. SCSI 버스 컨트롤러는 일반적으로 별도의 회로 기판(호스트 어댑터)으로 컴퓨터에 연결됩니다. 일반적으로 SCSI 프로토콜 메시지를 처리할 수 있는 프로세서, 마이크로코드 및 일부 전용 메모리가 포함되어 있습니다. 일부 장치에는 자체 내장 컨트롤러가 있습니다.

I/O 포트는 일반적으로 상태 레지스터, 제어 레지스터, 데이터 인 레지스터, 데이터 출력 레지스터 등 4개의 레지스터로 구성됩니다. 상태 레지스터에는 호스트가 읽을 수 있는 비트가 포함되어 있으며 현재 명령이 완료되었는지 여부, 레지스터의 데이터에서 바이트를 읽을 수 있는지 여부, 장치 오류가 발생했는지 여부 등의 상태를 나타냅니다.

호스트는 명령을 시작하거나 장치 모드를 변경하기 위해 제어 레지스터에 쓸 수 있습니다. 예를 들어 직렬 포트 제어 레지스터의 한 비트는 전이중 통신과 반이중 통신 중에서 선택하고, 다른 비트는 패리티를 활성화하고, 세 번째 비트는 워드 길이를 7비트 또는 8비트로 설정하고, 다른 비트는 직렬 포트에서 지원하는 속도 중 하나를 선택합니다. 레지스터의 데이터는 호스트가 읽어 입력을 얻고, 데이터 출력 레지스터는 호스트가 출력을 보내기 위해 기록하며, 데이터 레지스터는 일반적으로 1~4바이트입니다. 일부 컨트롤러에는 여러 바이트의 입력 또는 출력 데이터를 보유하여 컨트롤러의 용량을 데이터 레지스터 크기 이상으로 확장할 수 있는 FIFO 칩이 있습니다. FIFO 칩은 장치나 호스트가 데이터를 수신할 수 있을 때까지 소량의 데이터를 보관할 수 있습니다.

폴링: 호스트와 컨트롤러 간의 상호 작용을 위한 불완전한 프로토콜은 복잡할 수 있지만 기본 핸드셰이크 개념은 간단합니다. 컨트롤러는 상태 레지스터의 사용 중 비트를 통해 상태를 표시합니다(비트를 설정한다는 것은 비트에 1을 쓰는 것을 의미하고, 비트를 지우는 것은 비트에 0을 쓰는 것을 의미함). 작업 중일 때는 사용 중 비트를 설정하고, 다음 명령을 받아들일 준비가 되면 사용 중 비트를 지웁니다. 마스터는 명령 레지스터의 명령 준비 비트를 통해 원하는 신호를 보냅니다. 컨트롤러가 명령을 실행할 수 있으면 호스트는 명령 준비 비트를 설정합니다. 이 예에서 호스트는 다음과 같은 핸드셰이크를 통해 컨트롤러와 조정되어 포트를 통해 출력을 씁니다.

  1. 호스트는 비트가 지워질 때까지 사용 중인 비트를 반복해서 읽습니다.

  2. 호스트는 명령 레지스터에 쓰기 비트를 설정하고 데이터 출력 레지스터에 바이트를 씁니다.

  3. 호스트는 명령 준비 비트를 설정합니다.

  4. 컨트롤러는 명령 준비 비트가 설정된 것을 확인하면 이를 "사용 중"으로 설정합니다.

  5. 컨트롤러는 명령 레지스터를 읽고 쓰기 명령을 확인합니다.

  6. 데이터 출력 레지스터를 읽어 바이트를 얻고 장치에서 I/O 작업을 수행합니다.

  7. 컨트롤러는 명령 준비 비트를 지우고 상태 레지스터의 오류 비트를 지워 성공적인 장치 I/O를 나타내며 사용 중 비트를 지워 완료를 나타냅니다.

호스트는 바쁜 대기 또는 폴링 중입니다. 이는 바쁜 비트가 지워질 때까지 반복적으로 상태 레지스터를 읽는 루프 상태입니다. 이 접근 방식은 컨트롤러와 장치가 빠른 경우 합리적입니다. 그러나 대기 시간이 길어질 경우 호스트는 다른 작업으로 전환할 수 있습니다.

I/O 장치의 범주는 다음과 같습니다.

  • 사람이 읽을 수 있습니다. 프린터, 비디오 디스플레이 터미널, 키보드 등과 같은 컴퓨터 사용자와 통신하는 데 적합합니다.

  • 기계 판독 가능. 디스크 및 테이프 드라이브, 센서, 컨트롤러 및 액추에이터와 같은 전자 장치와의 통신에 적합합니다.

  • 의사소통. 디지털 회선 드라이버 및 모뎀과 같은 원격 장치와의 통신에 이상적입니다.

I/O 장치 간의 차이점:

  • 데이터 속도: 데이터 전송 속도는 몇 배나 다를 수 있습니다.

-응용 프로그램: 서로 다른 장치는 시스템에서 서로 다른 목적을 가지고 있습니다.

  • 제어 복잡성: 디스크는 훨씬 더 복잡하지만 프린터에는 간단한 제어 인터페이스가 필요합니다.

  • 전송 단위: 데이터는 바이트나 문자의 스트림 또는 더 큰 블록으로 전송될 수 있습니다.

  • 데이터 표현: 서로 다른 장치는 서로 다른 데이터 인코딩 방식을 사용합니다.

  • 오류 조건: 오류의 성격은 장치마다 다릅니다.

**직접 메모리 액세스(DMA)**는 다음과 같이 설명됩니다.

  • 프로세서의 지속적인 개입 없이 외부 장치와 주 메모리 간에 데이터 블록을 직접 전송할 수 있는 특수 제어 장치가 제공될 수 있습니다. 이 방법을 직접 메모리 액세스(DMA)라고 합니다.

  • 폴링 또는 인터럽트 소프트웨어와 함께 사용할 수 있습니다. 단일 I/O 작업으로 많은 바이트의 정보를 전송할 수 있는 디스크와 같은 장치에 특히 유용합니다. 인터럽트와 함께 사용하면 전체 데이터 블록이 전송될 때까지 CPU에 통보되지 않습니다.

  • 전송되는 각 바이트 또는 워드에 대해 메모리 주소와 데이터 전송을 제어하는 ​​모든 버스 신호를 제공해야 합니다.

  • 장치 드라이버를 통해 장치 컨트롤러와의 상호 작용을 관리합니다. 장치 드라이버는 운영 체제의 일부이지만 반드시 운영 체제 커널의 일부는 아닙니다.

  • 운영 체제는 사용자 응용 프로그램에 장치(예: UNIX의 문자 장치와 블록 장치)에 대한 단순화된 보기를 제공합니다. 일부 운영 체제(예: Linux)에서는 /dev 파일 시스템을 통해 장치에 액세스할 수도 있습니다.

  • 경우에 따라 운영 체제는 장치와 사용자 공간 프로그램(디스크 캐시, 네트워크 버퍼) 간에 전송되는 데이터를 버퍼링합니다.

  • 일반적으로 성능이 향상되지만 항상 그런 것은 아닙니다.

I/O 시스템의 주요 목적은 물리적 장치와 논리적 장치에 대한 액세스를 추상화하는 것입니다. 모든 파일 시스템의 파일에 액세스하는 것은 직렬 포트, USB 카메라 또는 프린터에 액세스하는 것과 달라야 합니다. I/O 시스템은 여러 구성 요소로 구성되며 일부는 사용자 모드이고 대부분은 커널 모드입니다. 가장 중요한 부분은 아래 그림에 나와 있습니다.

사용자 모드 프로세스는 다양한 Windows API를 사용하여 I/O 시스템을 호출하고 커널 측의 모든 파일 및 장치 작업은 I/O 관리자에 의해 시작됩니다. 요청(예: 읽기 또는 쓰기)은 IRP(I/O 요청 패킷)라는 커널 구조를 생성하고 요청 세부 정보를 채운 다음 이를 적절한 장치 드라이버에 전달하여 처리됩니다. 실제 파일의 경우 NTFS와 같은 파일 시스템 드라이버로 이동합니다. 아래 그림과 같이 이 과정은 일반적인 시스템 호출과 본질적으로 다르지 않습니다.

커널에 관한 한 I/O 작업은 항상 비동기식입니다. 즉, 호출 스레드가 제어권을 다시 얻을 수 있도록 드라이버가 작업을 시작하고 가능한 한 빨리 반환해야 함을 의미합니다. 그러나 원래 호출자는 동기적으로 호출하도록 선택할 수 있으며, 이 경우 I/O 관리자는 작업이 완료될 때까지 호출자를 대신하여 기다립니다. 고객의 관점에서 볼 때 이러한 유연성은 매우 편리합니다.

다음은 다양한 저장 매체의 속도를 비교한 것입니다.

18.11.2 디스크

18.11.2.1 디스크 구조

디스크 드라이브는 논리 블록의 대규모 1차원 배열로 처리됩니다. 논리 블록은 전송의 가장 작은 단위입니다. 논리 블록의 크기는 일반적으로 512바이트입니다. 논리 블록의 1차원 배열은 디스크의 섹터에 순차적으로 매핑됩니다. 섹터 0은 가장 바깥쪽 실린더에 있는 첫 번째 트랙의 첫 번째 섹터입니다. 해당 트랙, 해당 실린더의 나머지 트랙, 그리고 가장 바깥쪽에서 가장 안쪽까지 실린더의 나머지 부분을 통해 순차적으로 매핑하면 불량 섹터를 제외하고 논리적 주소와 물리적 주소가 쉬워야 합니다. 각속도가 일정하면 트랙당 섹터 수가 일정하지 않습니다.

디스크는 컴퓨터 시스템에 많은 양의 보조 저장 장치를 제공하며 모든 컴퓨터가 공유하는 I/O 장치로 생각할 수 있습니다. 크기와 속도는 다양하며 정보는 광학적 또는 자기적 방식으로 저장할 수 있습니다. 초기에는 2차 저장매체로 테이프를 사용했으나 디스크에 비해 접근 속도가 훨씬 느려 현재는 백업용으로 테이프를 활용하고 있다.

최신 디스크 드라이브는 논리 블록의 가장 작은 전송 단위인 대규모 1차원 배열로 알려져 있습니다. 디스크 I/O 작업의 실제 세부 사항은 컴퓨터 시스템, 운영 체제, I/O 채널 및 디스크 컨트롤러 하드웨어의 특성에 따라 달라집니다. 정보 저장의 기본 단위는 디스크 내부에서 외부로 이동할 수 있는 하나 이상의 읽기/쓰기 헤드 근처에서 회전하는 평평한 원형 미디어 디스크에 저장되는 섹터입니다. 디스크 드라이브가 작동 중일 때 디스크는 일정한 속도로 회전합니다. 읽거나 쓰려면 헤드가 원하는 트랙과 해당 트랙의 원하는 섹터 시작 부분에 있어야 합니다. 트랙 선택에는 이동 헤드 시스템에서 헤드를 이동하거나 고정 헤드 시스템에서 헤드를 전자적으로 선택하는 작업이 포함됩니다. 이러한 특성은 플로피 디스크, 하드 디스크, CD-ROM 및 DVD에 공통적으로 나타납니다.

최신 하드 드라이브의 사양을 볼 때 주의해야 할 점은 드라이버 소프트웨어에서 지정하고 사용하는 구조가 거의 항상 실제 형식과 다르다는 것입니다. 이전 디스크에서는 트랙당 섹터 수가 모든 실린더에서 동일했습니다. 최신 디스크는 여러 파티션으로 나누어져 있으며 내부 파티션보다 외부 파티션에 더 많은 섹터가 있습니다. 아래 그림 (a)는 두 영역으로 구성된 작은 디스크를 보여줍니다. 외부 구역에는 트랙당 32개의 섹터가 있습니다. 내부 구역에는 트랙당 16개의 섹터가 있습니다. WD 3000 HLFS와 같은 실제 디스크에는 일반적으로 16개 이상의 파티션이 있으며, 가장 안쪽 파티션에서 가장 바깥쪽 파티션으로 확장하면 파티션당 섹터 수가 약 4% 증가합니다.

(a) 두 개의 파티션이 있는 디스크의 물리적 구조. (b) 이 디스크에 가능한 가상 형상입니다.

각 트랙에 포함된 섹터 수에 대한 세부 정보를 숨기기 위해 대부분의 최신 디스크에는 운영 체제에 제공되는 가상 기하학이 있습니다. 소프트웨어는 트랙당 x 실린더, y 헤드 및 z 섹터로 작동하도록 지시되었습니다. 그런 다음 컨트롤러는 (x, y, z)에 대한 요청을 실제 실린더, 헤드 및 섹터에 다시 매핑합니다. 위의 (a)에서 물리적 디스크의 가능한 가상 형상은 (b)에 표시됩니다. 두 경우 모두 디스크에는 192개의 섹터가 있으며 게시된 배열만 실제 배열과 다릅니다.

PC의 경우 원래 IBM PC의 한계로 인해 이전 버전과의 호환성이 필요하기 때문에 이 세 가지 매개변수의 최대값은 일반적으로 (65535, 16, 63)입니다. 이 시스템에서는 16비트, 4비트 및 6비트 필드를 사용하여 이러한 숫자를 지정합니다. 실린더 및 섹터 번호는 1부터 시작하고 헤드 번호는 0부터 시작합니다. 이러한 매개변수를 사용하면 섹터당 512바이트, 가능한 최대 디스크는 31.5GB입니다. 이러한 제한을 극복하기 위해 모든 최신 디스크는 이제 논리 블록 주소 지정이라는 시스템을 지원합니다. 이 시스템에서는 디스크 구조에 관계없이 디스크 섹터에 0부터 시작하여 연속적으로 번호가 지정됩니다.

CPU 성능은 지난 10년 동안 기하급수적으로 증가하여 약 18개월마다 두 배로 증가했습니다. 디스크 성능에 대해서도 마찬가지입니다. 시간이 지날수록 CPU 성능과 (하드디스크) 성능의 격차는 점점 더 벌어지고, CPU 성능을 높이기 위해 병렬 처리를 사용하는 경우가 점점 더 많아지고 있습니다. 수년에 걸쳐 많은 사람들은 병렬 I/O도 좋은 아이디어일 수 있다는 것을 깨달았습니다. Patterson et al. 1988년 논문에서는 디스크 성능 및/또는 안정성을 향상시키는 데 사용할 수 있는 6개의 특정 디스크 구성을 제안했습니다. 이러한 아이디어는 업계에서 빠르게 채택되어 **RAID(Redundant Array of Inexpensive Disks, cheap Redundant Disk Array)**라는 새로운 I/O 장치가 탄생했습니다. 그 반대인 **SLED(Single Large Expensive Disk, 하나의 크고 비싼 디스크)**도 있습니다.

RAID 레벨 0~6. 백업 및 패리티 드라이브는 음영 처리되어 있습니다.

때때로 디스크가 잘못되고, 좋은 섹터가 갑자기 불량 섹터로 바뀔 수 있으며, 전체 드라이브가 실수로 손상될 수 있습니다. RAID는 일부 섹터의 오류나 드라이브 오류로부터 보호합니다. 그러나 애초에 잘못된 데이터가 남아 있기 때문에 쓰기 오류를 예방하지 못하며, 새 데이터로 바꾸지 않고 원본 데이터를 쓰기 때문에 충돌이 발생하는 것을 방지하지도 않습니다.

일부 애플리케이션의 경우 디스크 및 CPU 오류가 있는 경우에도 데이터가 손실되거나 손상되어서는 안 됩니다. 이상적으로는 디스크가 항상 오류 없이 작동해야 합니다. 불행하게도 이것은 불가능합니다. 구현할 수 있는 것은 다음과 같은 속성을 갖는 디스크 하위 시스템입니다. 쓰기 작업이 실행되면 디스크는 데이터를 올바르게 쓰거나 아무 작업도 수행하지 않아 기존 데이터의 무결성을 유지합니다. 이 시스템은 안정 저장소라고 불리며 소프트웨어로 구현됩니다(Lampon and Sturgis, 1979). 목표는 어떤 대가를 치르더라도 디스크 일관성을 유지하는 것입니다.

안정적인 저장소는 한 쌍의 동일한 디스크와 함께 작동하여 오류 없는 블록을 형성하는 해당 블록을 사용합니다. 오류가 없으면 두 드라이브의 해당 블록은 동일합니다. 둘 중 하나를 읽어도 동일한 결과를 얻을 수 있습니다. 이 목표를 달성하기 위해 다음 세 가지 작업이 정의됩니다.

  • 안정적인 글쓰기. 안정적인 쓰기에는 먼저 블록을 드라이브 1에 쓴 다음 다시 읽어서 올바르게 쓰여졌는지 확인하는 작업이 포함됩니다. 그렇지 않은 경우 쓰기 및 다시 읽기는 작동할 때까지 최대 n회까지 다시 수행됩니다. n 연속 실패 후 블록은 예비 디스크에 다시 매핑되고 시도해야 하는 예비 디스크 수에 관계없이 성공할 때까지 작업이 반복됩니다. 드라이브 1에 대한 쓰기가 성공한 후 드라이브 2의 해당 블록은 필요한 경우 최종적으로 성공할 때까지 반복적으로 기록되고 다시 읽혀집니다. CPU 충돌 없이 안정적인 쓰기가 완료되면 블록이 두 드라이브 모두에 ​​올바르게 기록되고 두 드라이브 모두에서 확인되었습니다.

  • 읽기가 안정적입니다. 안정적인 읽기는 드라이브 1에서 첫 번째 읽기 블록을 읽습니다. 이로 인해 잘못된 ECC가 생성되면 읽기가 최대 n번까지 다시 시도됩니다. 이들 모두가 잘못된 ECC를 제공하는 경우 드라이브 2에서 해당 블록을 읽습니다. 안정적인 쓰기에 성공하면 두 개의 양호한 블록 복사본이 남고 합리적인 시간 간격 내에 두 드라이브 모두에서 동일한 블록이 자발적으로 손상될 가능성이 무시할 수 있다는 점을 고려하면 안정적인 읽기는 항상 성공합니다.

  • 충돌 복구. 충돌 후 복구 프로그램은 두 디스크를 모두 스캔하고 해당 블록을 비교합니다. 한 쌍의 블록이 모두 좋고 동일하다면 아무 것도 할 수 없습니다. 그 중 하나에서 ECC 오류가 발생하면 해당 불량 블록이 해당 양호한 블록으로 덮어쓰여집니다. 한 쌍의 블록이 모두 양호하지만 서로 다른 경우 드라이브 1의 블록이 드라이브 2에 기록됩니다.

18.11.2.2 디스크 성능 매개변수

디스크 드라이브가 실행 중일 때 디스크는 일정한 속도로 회전합니다. 읽거나 쓰려면 헤드가 원하는 트랙과 해당 트랙의 원하는 섹터 시작 부분에 있어야 합니다. 트랙 선택에는 이동 헤드 시스템에서 헤드를 이동하거나 고정 헤드 시스템에서 헤드를 전자적으로 선택하는 작업이 포함됩니다. 이동식 헤드 시스템에서는 헤드가 트랙에 위치하는 데 걸리는 시간을 탐색 시간이라고 합니다. 트랙이 선택되면 디스크 컨트롤러는 해당 섹터가 회전하여 헤드와 정렬될 때까지 기다립니다. 섹터가 헤드에 도달하기 시작하는 데 걸리는 시간을 회전 지연 또는 회전 지연이라고 합니다. 탐색 시간(있는 경우)과 회전 대기 시간의 합은 액세스 시간, 즉 읽기 또는 쓰기 위치에 도달하는 데 걸리는 시간과 같습니다. 헤드가 제자리에 있으면 섹터가 헤드 아래로 이동하면서 읽기 또는 쓰기 작업이 수행됩니다. 이는 작업의 데이터 전송 부분이며 전송에 걸리는 시간이 전송 시간입니다.

탐색 시간은 디스크 암을 원하는 트랙으로 이동하는 데 필요한 시간입니다. 단정하기 어려운 수량인 것으로 나타났습니다. 탐색 시간은 두 가지 주요 구성 요소, 즉 초기 공격 시간, 액세스 암이 속도에 도달한 후 트랙을 건너는 데 걸리는 시간으로 구성되며 다음과 같이 계산됩니다.

Ts=mn+s

여기서: Ts는 검색 시간, n은 트랙 탐색 시간, m은 디스크 드라이브에 따라 달라지는 상수, s는 시작 시간입니다.

회전 대기 시간은 디스크가 디스크 헤드에 필요한 섹터로 회전할 때까지 기다리는 데 걸리는 추가 시간입니다.

회전 지연: 디스크(플로피 디스크 제외)의 회전 속도는 3600rpm에서 15000rpm까지 다양합니다. 후자의 속도에서는 4밀리초마다 한 번씩 회전합니다. 따라서 평균 회전 지연은 2밀리초입니다. 플로피 디스크는 일반적으로 300~600rpm으로 회전합니다. 따라서 평균 대기 시간은 100~50밀리초입니다.

디스크 대역폭은 전송된 총 바이트 수를 첫 번째 요청 서비스와 마지막 전송 완료 사이의 총 시간으로 나눈 값입니다.

전송 시간: 디스크 간 전송 시간은 디스크의 다음 회전 속도에 따라 달라집니다.

T=brN

그 중: T는 전송 시간, b는 전송될 바이트 수, r은 트랙의 바이트 수, N은 회전 속도, 단위는 rpm입니다. 따라서 총 평균 접속 시간은 다음과 같이 표현될 수 있다.

Ta=Ts+12r+brN

18.11.2.3 디스크 스케줄링

일련의 I/O 요청을 충족하는 데 필요한 헤더 수는 성능에 영향을 미치며, 필요한 디스크 드라이브와 컨트롤러를 사용할 수 있는 경우 요청을 즉시 처리할 수 있습니다. 장치나 컨트롤러가 사용 중인 경우 새 서비스 요청은 해당 드라이브에 대해 보류 중인 요청 대기열에 배치됩니다. 요청이 완료되면 운영 체제는 보류 중인 다음 요청을 선택하여 서비스를 제공합니다. 다양한 유형의 스케줄링 알고리즘은 다음과 같습니다.

  • 선착순(FCFS)

가장 간단한 스케줄링 형태는 FIFO(선입선출) 스케줄링으로, 대기열의 항목을 순차적으로 처리하고 요청이 수신된 순서대로 처리됩니다. 이 알고리즘은 공정하지만 가장 빠른 서비스를 제공하지 않으며 총 검색 시간을 최소화하기 위해 특별한 타이밍이 필요하지 않습니다.

이 전략의 장점은 공정성입니다. 모든 요청이 처리되고 요청이 수신된 순서대로 처리되기 때문입니다. FIFO를 사용하면 소수의 프로세스에만 액세스가 필요하고 클러스터 파일 섹터에 대한 요청이 많은 경우 좋은 성능을 얻을 수 있습니다.

예: 실린더의 블록에 대한 I/O를 요청하는 디스크 대기열을 생각해 보세요. 98, 183, 37, 122, 14, 124, 65, 67.

헤드가 처음에 53에 있으면 먼저 53에서 98로 이동한 다음 183으로 이동한 다음 37, 122, 14, 124, 65, 67로 이동하여 헤드 640 실린더를 이동합니다. 122에서 14로 갔다가 다시 124로 돌아가면 이 스케줄링 알고리즘의 문제점을 알 수 있습니다. 실린더 37과 14에 대한 요청을 122와 124 전후에 함께 처리할 수 있다면 전체 이동량이 크게 줄어들고 성능이 향상될 수 있습니다.

  • SSTF(가장 짧은 탐색 시간부터 우선)

SSTF는 먼저 현재 헤더 위치에서 검색 시간이 가장 짧은 요청을 선택합니다. 이는 일부 요청이 제대로 처리되지 않을 수 있는 SJF 스케줄링의 한 형태입니다. 헤드가 통과하는 실린더 수에 따라 탐색 시간이 증가하므로 SSTF는 현재 실린더 위치에 가장 가까운 보류 중인 요청을 선택합니다. 아래 그림은 236개 실린더의 전체 헤드 이동을 보여줍니다. 예: 실린더 98, 183, 37, 122, 14, 124, 65, 67의 블록에 대한 I/O를 요청하는 디스크 대기열을 생각해 보세요.

헤드가 처음에 53에 있으면 가장 가까운 것은 실린더 65, 다음은 67, 그 다음은 37입니다. 이는 98보다 67에 더 가깝습니다. 따라서 37을 제공하고 14, 98, 122, 124로 계속되고 마지막으로 183을 제공하여 총 236개의 실린더만 이동합니다.

SSTF는 본질적으로 특정 요청의 기아를 유발할 수 있는 SJF의 한 형태이며 FCFS에 비해 크게 개선되었지만 최적은 아닙니다.

  • 스캔

엘리베이터 알고리즘이라고도 하는 SCAN 알고리즘은 트랙 0에서 스캔을 시작하고 가장 높은 번호의 트랙을 향해 이동하며 트랙이 통과할 때 트랙에 대한 모든 요청을 처리합니다. 디스크 암은 디스크의 한쪽 끝에서 시작하여 다른 쪽 끝을 향해 이동하며 디스크의 다른 쪽 끝에 도달할 때까지 요청을 처리합니다. 이 지점에서 헤드는 반대 방향으로 이동하고 서비스가 계속됩니다.

아래 그림은 208개 실린더의 전체 헤드 이동을 보여줍니다. 그러나 요청 밀도가 고르게 높으면 디스크 반대쪽 끝의 밀도가 가장 높고 대기 시간도 가장 길어집니다. 예: 실린더 98, 183, 37, 122, 14, 124, 65, 67의 블록에 대한 I/O를 요청하는 디스크 대기열을 생각해 보세요.

디스크 헤드가 처음에 53에 있고 헤드가 0을 향해 이동하면 37이 제공되고 그 다음에는 14가 제공됩니다. 실린더 0에서 암은 역전되어 65, 67, 98, 122, 124 및 183을 서비스하는 디스크의 다른 쪽 끝을 향해 이동합니다. 요청이 헤드에서 바로 도착하면 즉시 서비스되며, 헤드 뒤의 요청은 암이 다른 쪽 끝에 도달하여 방향을 바꿀 때까지 기다려야 합니다.

탐색 시간을 최소화하기 위해 항상 다음으로 가장 최근의 요청을 처리할 수 있습니다. 아래 그림의 요구 사항에 따르면 그림 하단의 지그재그 선으로 표시된 것처럼 순서는 12, 9, 16, 1, 34, 36입니다. 이 순서대로 팔의 움직임은 1, 3, 7, 15, 33, 2이며 총 61개의 실린더입니다. 이 알고리즘은 SSF(Shortest Seek First)라고 불리며 FCFS에 비해 전체 팔 움직임을 거의 절반으로 줄입니다.

  • 원형 스캔(C-SCAN)

C-SCAN은 스캔을 한 방향으로 제한하는 정책을 통해 더 균일한 대기 시간을 제공하도록 설계된 SCAN의 변형입니다. SCAN과 유사하게 C-SCAN은 디스크 끝에서 요청을 서비스하는 다른 위치로 헤드를 이동하고, 헤드가 반대쪽 끝에 도달하면 반환 시 어떠한 요청도 처리하지 않고 즉시 디스크의 시작 부분으로 돌아갑니다.

C-SCAN은 실린더를 최종 실린더부터 첫 번째 실린더까지 순환 목록으로 처리하여 새로운 요청으로 인해 발생하는 최대 지연 시간을 줄입니다. 예: 실린더 98, 183, 37, 122, 14, 124, 65, 67의 블록에 대한 I/O를 요청하는 디스크 대기열을 생각해 보세요.

  • 보세요

SCAN과 C-SCAN 모두 헤드부터 시작하여 디스크의 전체 너비에 걸쳐 디스크 암을 한 방향으로 이동합니다. 해당 방향으로 더 이상 요청이 없으면 해당 방향에서 가장 가까운 궤적에 대한 요청이 충족되고 헤드가 이동하고 반대 방향을 반복합니다. 이 알고리즘은 각 회로의 가장 안쪽 및 가장 바깥쪽 트랙과 유사합니다. 실제로 두 알고리즘 모두 이런 방식으로 구현되지 않습니다. 팔은 각 방향의 마지막 요청에만 도달합니다. 그런 다음 역전되어 디스크 끝까지 가지 않습니다.

이러한 버전의 SCAN 및 CSCAN은 주어진 방향으로 계속 진행하기 전에 요청을 찾기 때문에 Look 및 C-Look 일정이라고 합니다. 예: 실린더 98, 183, 37, 122, 14, 124, 65, 67의 블록에 대한 I/O를 요청하는 디스크 대기열을 생각해 보세요.

위의 여러 디스크 스케줄링 알고리즘 중에서 적절한 것을 어떻게 선택합니까? 일반적인 권장 사항은 다음과 같습니다.

  • SSTF는 일반적이며 FCFS보다 성능이 향상됩니다.

  • SCAN 및 C-SCAN 알고리즘은 디스크에 큰 부하를 가하는 시스템에 더 적합하며 기아 문제가 적습니다.

  • SSTF 또는 Look은 기본 알고리즘에 대한 합리적인 선택입니다.

  • 스케줄링 알고리즘의 성능은 요청 수와 유형에 따라 달라집니다.

  • 디스크 서비스 요청은 파일 할당 방식에 따라 영향을 받을 수 있습니다.

  • 디스크 스케줄링 알고리즘은 운영 체제의 별도 모듈로 작성되어야 하며 필요한 경우 다른 알고리즘으로 교체할 수 있습니다. SSTF 또는 LOOK은 기본 알고리즘에 대한 합리적인 선택입니다.

18.11.3 파일

파일은 유사한 기록의 집합이며, 해당 데이터가 파일에 들어 있지 않으면 2차 저장소에 쓸 수 없습니다. 파일은 프로그램과 데이터를 나타내며 데이터는 숫자, 영숫자, 알파벳 또는 이진수일 수 있습니다. 소스 프로그램, 대상 프로그램, 실행 프로그램, 수치 데이터, 급여 기록기, 그래픽 이미지, 녹음 등 다양한 유형의 정보를 파일에 저장할 수 있습니다.

파일을 저장할 장소를 제공하기 위해 대부분의 PC 운영 체제에는 파일을 그룹화하는 방법으로 디렉터리 개념이 있습니다. 예를 들어, 학생은 자신이 수강하고 있는 각 과정(해당 과정에 필요한 프로그램)에 대한 하나의 디렉토리, 이메일용 디렉토리, 월드 와이드 웹(World Wide Web) 홈 페이지용 디렉토리를 각각 가질 수 있습니다. 그런 다음 디렉터리를 생성하고 삭제하려면 시스템 호출이 필요합니다. 기존 파일을 디렉터리에 배치하고 디렉터리에서 파일을 제거하기 위한 호출도 제공됩니다. 디렉토리 항목은 파일일 수도 있고 다른 디렉토리일 수도 있습니다. 이 모델은 또한 아래 그림과 같이 파일 시스템의 계층 구조를 생성합니다.

파일 속성은 운영 체제에 따라 다르며 일반적인 파일 속성은 다음과 같습니다.

  • 이름: 기호 파일 이름은 사람이 읽을 수 있는 형식으로 저장되는 유일한 정보입니다.

  • 식별자: 파일 시스템에서 파일을 식별하는 데 사용되는 고유한 표시(일반적으로 숫자)로 읽을 수 없는 파일 이름입니다.

  • 유형: 이 정보는 다양한 유형의 시스템을 지원하는 데 필요합니다.

  • 위치: 장치에 대한 포인터와 해당 장치에 있는 파일의 위치입니다.

  • 크기: 파일의 현재 크기와 가능한 최대 허용 크기입니다.

  • 보호: 접근 제어 정보는 읽기, 쓰기, 실행 등의 작업을 수행할 수 있는 사람을 결정합니다.

  • 시간, 데이터, 사용자 ID: 이 정보는 생성, 최종 수정 및 최종 사용을 위해 저장되어야 합니다. 이 데이터는 보호, 보안 및 사용 모니터링에 도움이 됩니다.

속성 분석하다

보호하다 파일에 액세스할 수 있는 사람과 방법

비밀번호 파일에 접근하려면 비밀번호가 필요합니다

크리에이터 파일을 만든 사람의 작성자 ID

소유자 현재 소유자

읽기 전용 표시 0은 읽기/쓰기를 의미하고, 1은 읽기 전용을 의미합니다.

표시 숨기기 0은 정상, 1은 목록에 표시되지 않음을 의미합니다.

시스템 플래그 0은 일반 파일을 나타내고, 1은 시스템 파일을 나타냅니다.

문서 마크업 0개가 백업되었습니다. 1개를 백업해야 합니다.

ASCII, 이진 표기법 0은 ASCII 파일을 의미하고, 1은 바이너리 파일을 의미합니다.

랜덤 액세스 태그 0은 순차 액세스 전용, 1은 랜덤 액세스용

임시 표시 0은 정상을 의미하고, 1은 프로세스가 종료될 때 파일을 삭제하는 데 사용됩니다.

자물쇠 표시 0은 잠금 해제됨을 의미하고, 0이 아닌 경우 잠김을 의미합니다.

레코드 길이 레코드의 바이트 수

주요 위치 각 레코드 내 키의 오프셋

채권 길이 키 필드의 바이트 수

생성 시간 파일이 생성된 날짜와 시간

마지막 액세스 시간 파일에 마지막으로 액세스한 날짜 및 시간

마지막으로 수정된 시간 파일이 마지막으로 변경된 날짜 및 시간

현재 크기 파일의 바이트 수

최대 크기 파일이 증가할 수 있는 바이트 수

파일은 유형에 따라 특정 정의 구조를 갖습니다.

  • 텍스트 파일: 라인으로 구성된 일련의 문자입니다.

  • 객체 파일: 시스템 링커가 이해할 수 있는 청크로 구성된 일련의 바이트입니다.

  • 실행 파일: 로더가 메모리에 배치하고 실행할 수 있는 일련의 코드 세그먼트입니다.

  • 소스 파일: 일련의 서브루틴 및 함수로, 각각은 선언으로 구성되고 그 뒤에는 실행 가능한 명령문이 따릅니다.

(a) 실행 파일; (b) 문서.

파일은 추상 데이터 유형입니다. 파일을 정의하려면 파일에서 수행할 수 있는 작업에 대해 생각해야 합니다. 파일에 대한 기본 작업은 다음과 같습니다.

  • 파일 생성: 두 단계가 필요합니다. 먼저 파일 시스템에서 파일의 첫 번째 공간을 찾습니다. 둘째, 파일 시스템의 파일 이름과 위치를 기록하는 새 파일에 대한 항목이 디렉터리에 생성되어야 합니다.

  • 파일 쓰기: 시스템 호출은 주로 파일 쓰기에 사용됩니다. 시스템 호출은 파일 이름과 정보, 즉 파일에 기록할 정보를 지정합니다. 이름이 주어지면 시스템은 전체 디렉토리에서 파일을 검색합니다. 시스템은 파일의 다음 쓰기 위치를 가리키는 쓰기 포인터를 유지해야 합니다.

  • 파일 읽기: 파일 시스템 호출을 사용하여 파일을 읽으려면 파일 이름과 메모리 주소가 필요하며, 해당 디렉터리는 연관된 디렉터리를 찾기 위해 다시 검색되며, 시스템은 파일의 다음 읽기 위치에 대한 읽기 포인터를 유지해야 합니다.

  • 파일 삭제: 시스템은 삭제할 파일을 디렉터리에서 검색합니다. 항목이 발견되면 모든 여유 공간이 해제되어 다른 파일에서 다시 사용할 수 있습니다.

  • 파일 자르기: - 사용자는 파일 내용을 제거하되 해당 속성은 유지하기를 원할 수 있습니다. 잘라내기를 사용하면 사용자가 파일을 삭제한 다음 다시 생성하도록 강제하는 대신 파일 길이를 제외한 모든 속성을 변경하지 않고 그대로 유지할 수 있습니다.

  • 파일 내 재배치: - 디렉터리를 검색하여 적절한 항목을 찾고 현재 파일 위치를 지정된 값으로 설정합니다. 파일 내의 재배치에는 실제 I/O가 필요하지 않습니다. 파일 작업은 파일 조회라고도 합니다.

이러한 6가지 작업 외에도 두 가지 다른 작업에는 파일 끝에 새 정보를 추가하고 기존 파일의 이름을 바꾸는 작업이 포함됩니다. 이러한 기본 요소를 결합하여 다른 두 작업을 수행할 수 있습니다. 대부분의 파일 작업에는 전체 디렉터리에서 파일과 관련된 항목을 검색하는 작업이 포함됩니다. 이를 방지하기 위해 운영 체제는 열린 파일에 대한 정보가 포함된 작은 테이블(열린 테이블)을 유지합니다. 파일 작업이 요청되면 이 테이블의 인덱스로 파일이 지정됩니다. 따라서 검색이 필요하지 않습니다.

열린 파일과 관련된 정보는 다음과 같습니다.

  • 파일 포인터: 읽기 및 쓰기 시스템 호출의 일부로 오프셋을 포함하지 않는 시스템에서 시스템은 마지막 읽기 및 쓰기 위치를 현재 파일 위치 포인터로 추적해야 합니다. 이 포인터는 파일에서 작동하는 각 프로세스마다 고유합니다.

  • 파일 열기 횟수: 파일이 닫히면 운영 체제는 열린 파일 테이블 항목을 재사용해야 합니다. 그렇지 않으면 테이블 공간이 부족해질 수 있습니다. 여러 프로세스에서 파일이 열려 있을 수 있으므로 시스템은 열린 파일 테이블 항목을 삭제하기 전에 마지막 파일이 닫힐 때까지 기다려야 합니다. 카운터는 열리고 닫힌 복제본 수를 추적하며 마지막으로 닫혔을 때는 0입니다.

  • 파일의 디스크 위치: 디스크에서 파일을 찾는 데 필요한 정보는 모든 작업 시 디스크에서 파일을 읽을 필요가 없도록 메모리에 보관됩니다.

  • 접근 권한: 각 프로세스는 접근 모드에서 파일을 열고, 이 정보는 각 프로세스 테이블에 저장되며, 운영 체제는 운영 체제가 후속 I/O 요청을 거부하도록 허용할 수 있습니다.

파일의 정보는 다양한 방법으로 액세스할 수 있습니다. 다양한 파일 액세스 방법은 다음과 같습니다.

  • 순차적 접근. 접근하는 가장 쉬운 방법입니다. 파일의 정보는 한 레코드씩 순차적으로 처리됩니다. 편집자와 컴파일러는 이러한 방식으로 파일에 액세스하며 일반적으로 파일에 대한 읽기 및 쓰기 작업을 수행합니다. 읽기 작업은 파일의 다음 부분을 읽고 다음 I/I 추적을 추적하는 파일 포인터를 자동으로 전진시킵니다. 쓰기 작업은 파일이 시작 부분에 바로 인접할 수 있도록 파일 끝에 추가됩니다. 순차적 액세스는 파일의 테이프 모델에 따라 다릅니다.

  • 직접 액세스 또는 상대 액세스. 파일이 고정 길이 논리 레코드로 구성되는 파일의 디스크 모델을 기반으로 모든 파일 블록에 대한 임의 액세스를 허용합니다. 이를 통해 프로그램은 어떤 순서로든 레코드를 빠르게 읽고 쓸 수 있으므로 임의의 블록을 읽거나 쓸 수 있습니다. 예: 사용자는 블록 13을 원하고 블록 99를 읽은 다음 블록 12를 쓸 수 있습니다. 즉각적인 결과를 제공하는 대량의 정보 레코드를 검색하려면 직접 액세스 방법이 적합합니다. 모든 운영 체제가 순차 액세스와 직접 액세스를 지원하는 것은 아니며 순차 액세스를 사용하는 운영 체제는 거의 없으며 일부 운영 체제는 직접 액세스를 사용합니다. 직접 액세스보다 순차 액세스를 시뮬레이션하는 것은 쉽지만 그 반대는 매우 비효율적입니다.

색인 방법: 색인은 개별 블록에 대한 포인터가 포함된 책 끝에 있는 색인과 같습니다. 파일에서 레코드를 찾으려면 인덱스를 검색한 다음 포인터를 사용하여 파일에 직접 액세스하여 필요한 레코드를 찾습니다. 대용량 파일의 경우 인덱스 파일 자체가 메모리에 보관되기에는 매우 클 수 있습니다. 인덱스 파일 자체에 대한 인덱스를 생성하는 솔루션입니다. 기본 인덱스 파일에는 실제 데이터 항목을 가리키는 보조 인덱스 파일에 대한 포인터가 포함됩니다. 두 가지 유형의 인덱스를 사용할 수 있습니다.

  • Exhaustive index: 마스터 파일의 각 레코드는 하나의 항목을 포함하며, 인덱스 자체는 순차 파일로 구성됩니다.

  • 부분 색인: 관심 필드가 가변 길이로 존재하는 레코드가 포함된 항목이며 일부 레코드에는 해당 필드가 포함되지 않습니다. 새 레코드가 기본 파일에 추가되면 모든 색인 파일을 업데이트해야 합니다.

파일을 추적하기 위해 파일 시스템에는 그 자체가 파일인 디렉터리나 폴더가 있는 경우가 많습니다.

디렉토리 시스템의 가장 간단한 형태는 모든 파일을 포함하는 하나의 디렉토리를 갖는 것입니다. 때로는 루트 디렉터리라고도 부르지만 유일한 디렉터리이므로 이름은 중요하지 않습니다. 초기 개인용 컴퓨터에서는 사용자가 한 명뿐이었기 때문에 이러한 시스템이 일반적이었습니다. 흥미롭게도 세계 최초의 슈퍼컴퓨터인 CDC 6600은 동시에 많은 사용자가 사용했음에도 불구하고 모든 파일에 대해 하나의 디렉터리만 가지고 있었습니다. 이는 소프트웨어 디자인을 단순하게 유지하기 위해 수행되었습니다.

아래 그림은 4개의 파일이 포함된 디렉토리가 있는 시스템의 예를 보여줍니다. 이 솔루션의 장점은 간단하고 파일을 빠르게 찾을 수 있다는 것입니다. 결국 볼 수 있는 곳은 단 하나뿐이다. 디지털 카메라 및 일부 휴대용 음악 플레이어와 같은 간단한 내장 장치에는 여전히 때때로 사용됩니다.

단일 레벨은 매우 간단하고 특수한 응용프로그램(최초의 개인용 컴퓨터에서도 사용됨)에는 적합하지만 수천 개의 파일을 가진 현대 사용자의 경우 파일이 모두 하나의 디렉토리에 있으면 아무것도 찾을 수 없습니다. 따라서 관련 파일을 함께 그룹화하는 방법, 즉 계층 구조(예: 디렉터리 트리)가 필요합니다. 이 접근 방식을 사용하면 자연스럽게 파일을 그룹화하려는 만큼 많은 디렉터리를 가질 수 있습니다. 또한 많은 기업 네트워크에서와 같이 여러 사용자가 공통 파일 서버를 공유하는 경우 각 사용자는 자신의 계층 구조에 대한 전용 루트 디렉터리를 가질 수 있습니다. 이 방법은 아래에 나와 있습니다. 여기서 루트 디렉터리에는 A, B, C 디렉터리가 포함되어 있으며 모두 서로 다른 사용자에게 속해 있으며 그 중 두 개는 작업 중인 프로젝트에 대한 하위 디렉터리를 만들었습니다.

사용자는 자신의 작업을 구성할 수 있는 강력한 구조화 도구를 제공하여 원하는 수만큼 하위 디렉터리를 만들 수 있습니다. 따라서 거의 모든 최신 파일 시스템은 이러한 방식으로 구성됩니다.

파일 시스템이 디렉토리 트리로 구성되면 파일 이름을 지정하는 방법이 필요합니다. 일반적으로 두 가지 다른 방법이 사용됩니다. 첫 번째 방법에서는 각 파일에 루트 디렉터리에서 파일까지의 경로로 구성된 절대 경로 이름이 있습니다. 예를 들어, /usr/ast/mailbox 경로는 루트 디렉터리에 usr 하위 디렉터리가 포함되어 있고, 이 하위 디렉터리에는 메일함 파일이 포함된 ast 하위 디렉터리가 포함되어 있음을 의미합니다. 절대 경로 이름은 항상 루트 디렉터리에서 시작하며 고유합니다. UNIX에서는 경로의 구성 요소를 /로 구분하고, Windows에서는 구분 기호를 , MULTICS에서는 >로 구분합니다. 따라서 세 시스템 모두에서 동일한 경로 이름은 다음과 같이 작성됩니다.

cpp
Windows  \usr\ast\mailbox
UNIX     /usr/ast/mailbox
MULTICS  >usr>ast>mailbox

Unix 디렉토리 트리의 예는 다음과 같습니다.

18.11.3.1 파일 시스템 구현

이제 파일 시스템에 대한 사용자의 관점에서 구현자의 관점으로 이동할 때입니다. 사용자는 파일 이름 지정 방법, 파일에 허용되는 작업, 디렉터리 트리 모양 및 유사한 인터페이스 문제에 관심을 갖습니다. 구현자는 파일과 디렉터리가 저장되는 방식, 디스크 공간이 관리되는 방식, 모든 것이 효율적이고 안정적으로 작동하는 방식에 관심이 있습니다. 아래에서는 문제와 장단점을 이해하기 위해 이러한 영역 중 일부를 조사합니다.

파일 시스템 소프트웨어 아키텍처.

파일 시스템은 디스크에 저장됩니다. 대부분의 디스크는 하나 이상의 파티션으로 나눌 수 있으며 각 파티션에는 독립적인 파일 시스템이 있습니다. 디스크의 섹터 0은 MBR(마스터 부트 레코드)이라고 하며 컴퓨터를 부팅하는 데 사용됩니다. MBR의 끝에는 파티션 테이블이 포함되어 있습니다. 이 테이블은 각 파티션의 시작 주소와 끝 주소를 제공합니다. 테이블의 파티션이 활성 파티션으로 표시됩니다. 컴퓨터가 부팅되면 BIOS는 MBR을 읽고 실행합니다. MBR 프로그램이 수행하는 첫 번째 작업은 활성 파티션을 찾고 첫 번째 블록(부팅 블록이라고 함)을 읽은 다음 실행하는 것입니다. 부트 블록의 프로그램은 파티션에 포함된 운영 체제를 로드합니다. 일관성을 위해 각 파티션은 부팅 가능한 운영 체제가 포함되어 있지 않더라도 부팅 블록으로 시작됩니다. 또한 향후에는 하나가 포함될 수 있습니다.

부팅 블록에서 시작하는 것 외에도 디스크 파티션의 레이아웃은 파일 시스템마다 다릅니다. 파일 시스템에는 일반적으로 아래 이미지에 표시된 대로 여러 항목이 포함되어 있습니다. 첫 번째는 파일 시스템에 대한 모든 주요 매개변수를 포함하고 컴퓨터가 시작되거나 파일 시스템을 처음 터치할 때 메모리로 읽어지는 슈퍼블록입니다. 슈퍼 블록의 일반적인 정보에는 파일 시스템 유형을 식별하는 매직 넘버, 파일 시스템의 블록 수 및 기타 주요 관리 정보가 포함됩니다.

파일 시스템에서 사용 가능한 블록에 대한 다음 정보는 예를 들어 비트맵이나 포인터 목록의 형태로 나타날 수 있습니다. 다음은 파일에 대한 모든 정보를 설명하는 각 파일마다 하나씩 데이터 구조 세트인 i-노드일 수 있습니다. 그 다음에는 파일 시스템 트리의 최상위를 포함하는 루트 디렉터리가 있을 수 있습니다. 마지막으로 디스크의 나머지 부분에는 다른 모든 디렉터리와 파일이 포함됩니다.

파일 관리 요소.

범용 파일 구성.

Windows는 Windows 95, MS-DOS 및 OS/2에서 실행되는 FAT(파일 할당 테이블)를 포함하여 다양한 파일 시스템을 지원합니다. 그러나 Windows 개발자는 워크스테이션과 서버의 고급 요구 사항을 충족하도록 설계된 새로운 파일 시스템인 Windows 파일 시스템(NTFS)도 설계했습니다. 고급 애플리케이션 예:

  • 파일 서버, 컴퓨팅 서버, 데이터베이스 서버와 같은 클라이언트/서버 애플리케이션.

  • 자원 집약적인 엔지니어링 및 과학 응용.

  • 대규모 기업 시스템을 위한 웹 애플리케이션.

NTFS는 우아하고 단순한 파일 시스템 모델을 기반으로 구축된 유연하고 강력한 파일 시스템입니다. NTFS의 가장 주목할만한 기능은 다음과 같습니다.

  • 복구 가능성: 새로운 Windows 파일 시스템에 대한 요구 사항 목록의 맨 위에는 시스템 충돌 및 디스크 오류로부터 복구하는 기능이 있습니다. 이러한 오류가 발생하는 경우 NTFS는 디스크 볼륨을 다시 작성하고 일관된 상태로 복원할 수 있습니다. 이는 트랜잭션 처리 모델을 사용하여 파일 시스템을 변경함으로써 수행됩니다. 각각의 주요 변경 사항은 원자적 작업으로 처리되며 완전히 실행되거나 전혀 실행되지 않습니다. 실패 시 처리 중이던 각 트랜잭션은 이후에 중단되거나 완료됩니다. 또한 NTFS는 중요한 파일 시스템 데이터에 중복 저장소를 사용하므로 디스크 섹터 오류로 인해 파일 시스템 구조 및 상태를 설명하는 데이터가 손실되지 않습니다.

  • 보안: NTFS는 Windows 개체 모델을 사용하여 보안을 강화하고, 열린 파일은 보안 속성을 정의하는 보안 설명자가 있는 파일 개체로 구현됩니다.

  • 대용량 디스크 및 대용량 파일: NTFS는 FAT를 포함한 대부분의 다른 파일 시스템보다 더 효율적으로 대용량 디스크와 대용량 파일을 지원합니다.

  • 다중 데이터 스트림: 파일의 실제 내용은 바이트 스트림으로 처리됩니다. NTFS에서는 단일 파일에 대해 여러 데이터 스트림을 정의할 수 있습니다. 이 기능의 유틸리티에 대한 예로는 원격 Macintosh 시스템이 Windows를 사용하여 파일을 저장하고 검색할 수 있다는 것입니다. Macintosh에서 모든 파일에는 파일 데이터와 파일 정보가 포함된 리소스 포크라는 두 가지 구성 요소가 있습니다. NTFS는 이 두 구성 요소를 두 개의 데이터 스트림으로 처리합니다.

  • 범용 인덱싱 기능: NTFS는 속성 모음을 각 파일과 연결합니다. 파일 관리 시스템의 파일 설명 세트는 관계형 데이터베이스로 구성되므로 모든 속성으로 파일을 색인화할 수 있습니다.

NTFS는 다음과 같은 디스크 저장소 개념을 사용합니다.

  • 섹터: 디스크의 가장 작은 물리적 저장 단위입니다. 바이트 단위의 데이터 크기는 2의 거듭제곱이며 거의 항상 512바이트입니다.

  • 클러스터: 하나 이상의 연속(동일 트랙에서 서로 옆에 있는) 섹터입니다. 섹터의 클러스터 크기는 2의 거듭제곱입니다.

  • 볼륨: 하나 이상의 클러스터로 구성된 디스크의 논리 파티션으로, 파일 시스템에서 공간을 할당하는 데 사용됩니다. 언제든지 볼륨은 파일 시스템 정보, 파일 모음, 파일에 할당할 수 있는 볼륨의 기타 할당되지 않은 공간으로 구성됩니다. 볼륨은 단일 디스크의 전부 또는 일부일 수도 있고, 여러 디스크에 걸쳐 확장될 수도 있습니다. 하드웨어 또는 소프트웨어 RAID 5를 사용하는 경우 볼륨은 여러 디스크에 걸쳐 있는 스트라이프로 구성됩니다. NTFS의 최대 볼륨 크기는 264바이트입니다.

볼륨 레이아웃.

NTFS는 시스템 충돌이나 디스크 오류가 발생한 후 파일 시스템을 일관된 상태로 복원할 수 있습니다. 아래 다이어그램과 결합하여 복구 가능성을 지원하는 핵심 요소는 다음과 같습니다.

  • I/O 관리자: NTFS의 기본 열기, 닫기, 읽기 및 쓰기 기능을 처리하는 NTFS 드라이버가 포함되어 있습니다. 또한 소프트웨어 RAID 모듈 FTDISK를 사용하도록 구성할 수 있습니다.

  • 로그 파일 서비스: 디스크 쓰기 로그를 유지합니다. 로그 파일은 시스템 오류가 발생할 경우 NTFS 형식의 볼륨을 복구하는 데 사용됩니다.

  • 캐시 관리자: 성능 향상을 위해 파일 읽기 및 쓰기 캐싱을 담당합니다. 캐시 관리자는 섹션 11.8에 설명된 지연된 쓰기 및 지연된 커밋 기술을 사용하여 디스크 I/O를 최적화합니다.

  • 가상 메모리 관리자: NTFS는 파일 참조를 가상 메모리 참조에 매핑하고 가상 메모리를 읽고 쓰는 방식으로 캐시된 파일에 액세스합니다.

Windows NTFS 구성요소.

18.11.3.2 구현 파일

파일 스토리지를 구현할 때 가장 중요한 문제는 어떤 디스크 블록이 어떤 파일에 해당하는지 추적하는 것이며, 운영 체제마다 다른 방법을 사용합니다. 다음 그림은 블록을 기록하는 방법을 보여줍니다.

  • 지속적 할당

가장 간단한 할당 방식은 각 파일을 디스크 블록의 연속 실행으로 저장하는 것입니다. 따라서 1KB 블록이 있는 디스크에서는 50KB 파일에 50개의 연속 블록이 할당됩니다. 2KB 블록의 경우 25개의 연속 블록이 할당됩니다.

아래 그림 (a)에서는 연속적인 스토리지 할당의 예를 볼 수 있으며, 왼쪽의 블록 0부터 시작하여 처음 40개의 디스크 블록을 보여줍니다. 처음에는 디스크가 비어 있다가 처음(블록 0)부터 시작하여 4블록 길이의 파일 a가 디스크에 기록되고, 나중에 파일 a의 끝 이후에 6블록의 파일 B가 기록됩니다.

각 파일은 새 블록의 시작 부분에서 시작하므로 파일 a가 실제로 3½ 블록인 경우 마지막 블록 끝에 낭비되는 공간이 있을 것입니다. 그림에는 총 7개의 파일이 표시되어 있으며, 각 파일은 이전 파일이 끝난 후의 블록부터 시작합니다. 색상은 파일을 쉽게 구분하기 위한 것일 뿐이며 저장에 관한 한 실제 의미는 없습니다.

(a) 7개 파일에 대한 디스크 공간을 연속적으로 할당합니다. (b) D, F 파일을 삭제한 후의 디스크 상태.

연속적인 파일 할당의 경우.

연속적인 파일 할당(압축 후)의 경우입니다.

연속적인 디스크 공간 할당에는 두 가지 중요한 이점이 있습니다. 첫째, 파일 블록의 위치 추적이 두 숫자, 즉 첫 번째 블록의 디스크 주소와 파일의 블록 수를 기억하는 것으로 줄어들 수 있기 때문에 구현하기 쉽습니다. 첫 번째 블록의 수량을 고려하면 다른 블록의 수량은 간단한 추가로 찾을 수 있습니다.

둘째, 한 번의 작업으로 전체 파일을 디스크에서 읽을 수 있으므로 읽기 성능이 매우 좋습니다(첫 번째 블록까지). 그 후에는 탐색 또는 회전 지연이 필요하지 않으므로 데이터는 디스크의 전체 대역폭으로 들어옵니다. 따라서 연속 할당은 구현하기 쉽고 성능도 좋습니다.

불행하게도 연속 할당에는 매우 심각한 단점도 있습니다. 시간이 지남에 따라 디스크가 조각화될 수 있습니다. 이것이 어떻게 발생하는지 보려면 위의 (b)를 참조하세요. 여기서는 두 개의 파일 D와 F가 삭제됩니다. 파일이 삭제되면 해당 블록은 자연스럽게 해제되어 디스크에 일정 기간의 사용 가능한 블록이 남습니다. 구멍 뒤에 있는 모든 블록, 아마도 수백만 개의 블록을 복사해야 하기 때문에 디스크는 그 자리에서 압축되지 않습니다. 디스크가 더 크다면 몇 시간 또는 며칠이 걸릴 수도 있습니다. 따라서 디스크는 결국 파일과 홀(Hole)로 구성됩니다.

처음에는 각 새 파일이 이전 파일 바로 다음에 디스크 끝에 기록될 수 있었기 때문에 이러한 조각화는 문제가 되지 않았습니다. 하지만 결국 디스크가 가득 차게 되므로 디스크를 압축하거나(비용이 많이 들지만) 구멍에 있는 여유 공간을 재사용해야 합니다. 공간을 재사용하려면 구멍 목록을 유지해야 하며 이는 가능합니다. 그러나 새 파일을 생성할 때 파일을 넣을 올바른 크기의 구멍을 선택할 수 있도록 최종 크기를 알아야 합니다.

이 디자인의 결과를 상상해보십시오. 사용자는 문서를 작성하기 위해 워드 프로세서를 시작합니다. 프로그램이 가장 먼저 묻는 것은 최종 문서의 바이트 수입니다. 이 질문에 답해야 합니다. 그렇지 않으면 프로그램이 계속 진행되지 않습니다. 주어진 개수가 너무 작은 것으로 판명되면 디스크 구멍이 가득 차서 나머지 파일을 넣을 공간이 없기 때문에 프로그램이 조기 종료되어야 합니다. 사용자가 1GB와 같이 비현실적으로 큰 숫자를 최종 크기로 지정하여 이 문제를 피하려고 하면 편집자는 그렇게 큰 구멍을 찾지 못하고 파일을 생성할 수 없다고 선언할 수 있습니다. 물론 사용자는 프로그램을 다시 시작할 수 있으며 이번에는 500MB라고 말할 수 있으며 적절한 취약점이 발견될 때까지 계속됩니다. 그러나 이 솔루션은 실현 가능하지 않습니다.

그러나 연속 할당이 가능하고 실제로 여전히 사용되는 상황이 하나 있습니다. 바로 CD-ROM입니다. 여기에서는 모든 파일 크기가 미리 알려져 있으며 나중에 CD-ROM 파일 시스템을 사용할 때 변경되지 않습니다. DVD의 상황은 좀 더 복잡합니다. 원칙적으로 90분짜리 영화를 약 4.5GB 길이의 파일로 인코딩할 수 있지만, 사용되는 파일 시스템인 UDF(Universal Disk Format, Universal Disk Format)는 파일 길이를 표현하기 위해 30비트 숫자를 사용하므로 파일 크기는 1GB로 제한됩니다. 따라서 DVD 영화는 일반적으로 3개 또는 4개의 1GB 파일로 저장되고, 각 파일은 연속적이며, 단일 논리 파일(영화)의 이러한 물리적 세그먼트를 익스텐트라고 합니다.

  • 연결된 목록 할당

파일을 저장하는 두 번째 방법은 아래 그림과 같이 각 파일을 디스크 블록의 연결 목록으로 저장하는 것입니다. 각 블록의 첫 번째 단어는 다음 블록에 대한 포인터로 사용됩니다. 블록의 나머지 부분은 데이터에 사용됩니다.

연속 할당과 달리 이 방법은 모든 디스크 블록을 사용할 수 있으며 디스크 조각화로 인해 공간이 손실되지 않습니다(마지막 블록의 내부 조각화 제외). 게다가 디렉토리 항목이 첫 번째 블록의 디스크 주소만 저장하는 것만으로도 충분합니다. 나머지는 거기에서 찾을 수 있습니다.

반면, 파일을 순차적으로 읽는 것은 간단하지만, 임의 접근은 매우 느립니다. 블록 n에 도달하려면 운영 체제는 처음부터 시작하여 n- 이전의 한 블록을 한 번에 하나씩 읽어야 합니다. 분명히 많은 양의 콘텐츠를 읽을 때 속도가 매우 느려질 것입니다.

또한 포인터가 몇 바이트를 차지하기 때문에 블록의 데이터 저장량은 더 이상 2의 거듭제곱이 아닙니다. 치명적이지는 않지만 특별한 크기를 가진 프로그램은 많은 프로그램이 크기가 2의 거듭제곱인 블록을 읽고 쓰기 때문에 효율성이 떨어집니다. 각 블록의 처음 몇 바이트는 다음 블록에 대한 포인터로 채워지기 때문에 전체 블록 크기를 읽으려면 두 디스크 블록에서 정보를 가져와 연결해야 하며, 이는 복사로 인해 추가 오버헤드가 발생합니다.

  • 인메모리 테이블을 사용하여 연결리스트 할당

연결된 목록 할당의 두 가지 단점은 각 디스크 블록에서 포인터 단어를 가져와 메모리 내 테이블에 배치함으로써 제거됩니다. 아래 이미지는 위의 예에 대한 표를 보여줍니다. 두 그림 모두 두 개의 파일이 있습니다. 파일 A는 디스크 블록 4, 7, 2, 10, 12를 순서대로 사용하고, 파일 B는 디스크 블록 6, 3, 11, 14를 순서대로 사용합니다. 아래 표를 사용하면 블록 4에서 시작하여 체인을 끝까지 따라갈 수 있으며 블록 6에서도 동일한 작업을 수행할 수 있습니다. 두 체인 모두 유효한 블록 번호가 아닌 특수 표시(예: -1)로 표시됩니다. 이러한 메인 메모리의 테이블을 FAT(File Allocation Table)이라고 합니다.

메인 메모리의 파일 할당 테이블을 사용하여 연결 목록을 할당합니다.

이 구성을 사용하면 전체 블록을 데이터에 사용할 수 있으며 임의 액세스가 훨씬 쉽습니다. 파일에서 주어진 오프셋을 찾으려면 체인을 계속 따라가야 하지만 체인은 전적으로 메모리에 있으므로 디스크 참조를 만들지 않고도 따라갈 수 있습니다. 이전 방법과 마찬가지로 디렉토리 항목이 정수(시작 블록 번호)만 보유하고 파일 크기에 관계없이 모든 블록을 찾을 수 있으면 충분합니다.

이 접근 방식의 가장 큰 단점은 전체 테이블이 작동하려면 항상 메모리에 있어야 한다는 것입니다. 1TB 디스크와 1KB 블록 크기의 경우 테이블에는 10억 개의 항목이 필요하며, 각 항목은 10억 개의 디스크 블록에 해당하며 각 항목은 최소 3바이트여야 합니다. 조회 속도를 높이려면 4바이트여야 합니다. 따라서 테이블은 시스템이 공간에 최적화되어 있는지, 시간에 최적화되어 있는지에 따라 항상 3GB 또는 2.4GB의 메인 메모리를 차지하게 되므로 그다지 실용적이지 않습니다. 분명히 FAT 아이디어는 대형 디스크에는 잘 확장되지 않습니다. 이는 원래 MS-DOS 파일 시스템이지만 모든 Windows 버전은 여전히 ​​이를 완벽하게 지원합니다.

  • I 노드

어떤 블록이 어떤 파일에 속하는지 추적하는 마지막 방법은 파일 블록의 속성과 디스크 주소를 나열하는 I-노드라는 데이터 구조를 각 파일과 연결하는 것입니다. i-노드가 주어지면 파일의 모든 블록을 찾을 수 있습니다. 간단한 예가 아래에 나와 있습니다.

인메모리 테이블을 사용하는 링크된 파일과 비교할 때 이 방식의 가장 큰 장점은 해당 파일이 열려 있는 동안에만 i-노드가 메모리에 있으면 된다는 것입니다. 각 i-노드가 n바이트를 차지하고 한 번에 최대 k개의 파일을 열 수 있는 경우 열린 파일의 i-노드를 보유하는 배열이 차지하는 총 메모리는 k*n바이트에 불과하며 그만큼의 공간만 미리 예약하면 됩니다.

이 배열은 일반적으로 이전 섹션에서 설명한 파일 테이블이 차지하는 공간보다 훨씬 작습니다. 이는 모든 디스크 블록의 연결된 목록을 보유하는 데 사용되는 테이블의 크기가 디스크 자체에 비례하기 때문입니다. 디스크에 n개의 블록이 있는 경우 테이블에는 n개의 항목이 필요하며, 디스크가 커지면 테이블도 선형적으로 늘어납니다. 대조적으로, i-노드 방식에는 한 번에 열 수 있는 최대 파일 수에 비례하는 크기의 메모리 내 배열이 필요합니다. 디스크가 100GB, 1000GB 또는 10000GB인지는 중요하지 않습니다.

i-node의 한 가지 문제점은 각 노드에 고정된 수의 디스크 주소를 위한 공간이 있는 경우 파일이 이 제한을 초과하면 어떻게 됩니까? 한 가지 해결책은 데이터 블록의 마지막 디스크 주소를 예약하지 않고 위 이미지에 표시된 것처럼 더 많은 디스크 블록 주소를 포함하는 블록의 주소를 예약하는 것입니다. 보다 발전된 접근 방식은 디스크 주소를 포함하는 두 개 이상의 블록을 보유하거나 주소로 가득 찬 다른 디스크 블록을 가리키는 디스크 블록을 갖는 것입니다. 마찬가지로, Windows NTFS 파일 시스템은 비슷한 아이디어를 사용합니다. 즉, 더 큰 i-노드에만 작은 파일도 포함될 수 있습니다.

18.11.3.3 폴더 구현

파일을 읽으려면 먼저 열어야 합니다. 파일이 열리면 운영 체제는 사용자가 제공한 경로 이름을 사용하여 디스크에서 디렉터리 항목을 찾습니다. 디렉토리 항목은 디스크 블록을 찾는 데 필요한 정보를 제공합니다. 시스템에 따라 이 정보는 전체 파일의 디스크 주소(연속 할당 포함), 첫 번째 블록 번호(2개의 연결된 목록 방식) 또는 i-노드 번호일 수 있습니다. 모든 경우에 디렉토리 시스템의 주요 기능은 파일의 ASCII 이름을 데이터를 찾는 데 필요한 정보에 매핑하는 것입니다.

밀접하게 관련된 질문은 속성을 어디에 저장해야 하는가입니다. 모든 파일 시스템은 각 파일의 소유자, 생성 시간 등 다양한 파일 속성을 유지하며 해당 속성을 어딘가에 저장해야 합니다. 한 가지 확실한 가능성은 디렉토리 항목에 직접 저장하는 것입니다. 일부 시스템에서는 그렇게 합니다. 이 옵션은 아래 그림 (a)에 표시되어 있습니다. 이 단순한 설계에서 디렉토리는 고정된 크기의 항목 목록(고정 길이) 파일 이름, 파일 속성 구조 및 디스크 블록의 위치를 ​​설명하는 하나 이상의 디스크 주소(최대)를 포함하는 각 파일당 하나씩 구성됩니다.

(a) 디스크 주소와 디렉토리 항목 내의 속성이 있는 고정 크기 항목을 포함하는 간단한 디렉토리입니다. (b) 각 항목이 하나의 i-노드만을 참조하는 디렉터리입니다.

트리 구조의 폴더 사례입니다.

i-노드를 사용하는 시스템의 경우 디렉토리 항목이 아닌 i-노드에 속성을 저장할 수 있는 또 다른 방법이 있습니다. 이 경우 디렉토리 항목은 위의 (b)에 표시된 대로 파일 이름과 i-노드 번호만으로 더 짧아질 수 있습니다.

지금까지 우리는 파일 이름이 짧고 길이가 고정되어 있다고 가정했습니다. MS-DOS 파일에서 기본 이름은 1~8자이고 확장자는 선택적으로 1~3자입니다. UNIX 버전 7에서 파일 이름은 확장자를 포함하여 1-14자입니다. 그러나 거의 모든 최신 운영 체제는 더 긴 가변 길이 파일 이름을 지원합니다. 이러한 목표를 달성하는 방법은 무엇입니까?

가장 쉬운 방법은 파일 이름 길이 제한(보통 255자)을 설정한 다음 아래 디자인 중 하나를 사용하여 각 파일 이름에 대해 255단어를 예약하는 것입니다. 이 방법은 간단하지만 그렇게 긴 이름을 가진 파일이 거의 없기 때문에 디렉토리 공간을 많이 낭비합니다. 효율성을 이유로 다른 구조를 사용하는 것이 좋습니다.

대안은 모든 디렉토리 항목의 크기가 동일하다는 생각을 버리는 것입니다. 이 방법을 사용하면 각 디렉터리 항목에는 일반적으로 항목 길이로 시작하는 고정 섹션이 포함되며 그 뒤에는 일반적으로 소유자, 생성 시간, 보호 정보 및 기타 속성이 포함된 고정 형식 데이터가 옵니다. 이 고정 길이 헤더 뒤에는 아래 그림 (a)와 같이 빅 엔디안 형식(예: SPARC)으로 길이에 관계없이 실제 파일 이름이 옵니다. 이 예에는 project-budget, Personnel, foo라는 세 개의 파일이 있습니다. 각 파일 이름은 다이어그램에서 십자형 상자로 표시되는 특수 문자(일반적으로 0)로 끝납니다. 각 디렉토리 항목이 단어 경계에서 시작되도록 하기 위해 그림에서 음영 처리된 상자에 표시된 대로 각 파일 이름이 정수 단어 수로 채워집니다.

디렉터리에서 긴 파일 이름을 처리하는 두 가지 방법. (a) 라인업. (b) 더미를 만들어라.

18.11.3.4 공유 파일

여러 사용자가 프로젝트에서 함께 작업할 때 파일을 공유해야 하는 경우가 많습니다. 따라서 공유 파일이 다른 사용자에게 속한 다른 디렉터리에 동시에 나타나는 것이 편리한 경우가 많습니다. 아래 이미지는 공유 파일을 포함하는 파일 시스템을 보여줍니다. 이제 C의 파일만 B의 디렉터리에도 존재합니다. B의 디렉터리와 공유 파일 간의 연결을 링크라고 합니다. 파일 시스템 자체는 이제 트리가 아닌 DAG(방향성 비순환 그래프)입니다. 파일 시스템을 DAG로 사용하면 유지 관리가 복잡해지지만 생명도 마찬가지입니다.

공유 파일을 위한 파일 시스템이 포함되어 있습니다.

파일을 공유하는 것은 편리하지만 몇 가지 문제도 발생합니다. 첫째, 디렉터리에 디스크 주소가 포함되어 있으면 파일이 링크될 때 디스크 주소의 복사본이 B의 디렉터리에 생성되어야 합니다. B 또는 C가 이후에 파일에 추가하면 새 블록은 추가를 수행한 사용자의 디렉터리에만 나열됩니다. 다른 사용자는 이러한 변경 사항을 볼 수 없으므로 공유 목적이 무산됩니다. 이 문제는 두 가지 방법으로 해결할 수 있습니다.

  • 첫 번째 해결 방법: 디스크 블록은 디렉터리에 나열되지 않고 파일 자체와 관련된 작은 데이터 구조에 나열되며 그런 다음 디렉터리는 작은 데이터 구조만 가리킵니다. 이는 UNIX(작은 데이터 구조가 i-노드인 경우)에서 사용되는 접근 방식입니다.

  • 두 번째 해결 방법: B는 시스템에 LINK 유형의 새 파일을 생성하도록 요청한 다음 해당 파일을 B의 디렉터리에 입력하여 C의 파일에 연결합니다. 새 파일에는 연결된 파일의 경로 이름만 포함됩니다. B가 링크된 파일을 읽을 때 운영 체제는 읽고 있는 파일이 LINK 유형임을 확인하고 파일 이름을 찾아 파일을 읽습니다. 이 접근 방식을 기존(하드) 링크와 달리 기호 링크라고 합니다.

이러한 각 방법에는 단점이 있습니다. 첫 번째 방법에서는 B가 공유 파일에 링크할 때 i-노드가 파일 소유자를 C로 기록합니다. 링크를 생성해도 소유권은 변경되지 않지만(아래 이미지 참조) i-노드의 링크 수가 늘어나므로 시스템은 현재 파일을 가리키는 디렉토리 항목 수를 알 수 있습니다.

(a) 연결 전 상황. (b) 링크가 생성된 후. (c) 원래 소유자가 파일을 삭제한 후.

C가 파일을 삭제하려고 시도하면 시스템에 문제가 발생합니다. 파일이 삭제되고 i-노드가 지워지면 B에는 잘못된 i-노드를 가리키는 디렉터리 항목이 있게 됩니다. i-노드가 나중에 다른 파일에 재할당되면 B의 링크는 잘못된 파일을 가리키게 됩니다. 시스템은 i-노드의 개수를 통해 파일이 아직 사용 중임을 알 수 있지만 삭제할 수 있도록 파일에 대한 모든 디렉토리 항목을 찾는 쉬운 방법은 없습니다. 디렉토리 수는 무제한이므로 디렉토리에 대한 포인터는 inode에 저장할 수 없습니다.

해야 할 유일한 일은 위의 (C)에 표시된 대로 C에 대한 디렉토리 항목을 삭제하고 i-노드를 변경하지 않고 개수를 1로 설정하는 것입니다. 이제 B가 C가 소유한 파일에 대한 디렉터리 항목을 가진 유일한 사용자인 상황이 발생했습니다. 시스템이 계산 중이거나 할당량이 있는 경우 C는 B가 삭제하기로 결정할 때까지 파일 수를 계속 계산합니다. 삭제하면 개수가 0이 되고 파일이 삭제됩니다.

심볼릭 링크를 사용하면 실제 소유자만이 i-노드에 대한 포인터를 가지므로 이 문제가 발생하지 않습니다. 파일에 링크하는 사용자는 i-노드 포인터가 아닌 경로 이름만 가집니다. 소유자가 파일을 삭제하면 파기됩니다. 시스템이 파일을 찾을 수 없으면 기호 링크를 통해 파일을 사용하려는 후속 시도가 실패합니다. 심볼릭 링크를 제거해도 파일에는 전혀 영향을 미치지 않습니다.

18.11.3.5 로그 구조 파일 시스템

기술 변화로 인해 현재 파일 시스템에 압박이 가해지고 있습니다. 특히, CPU는 점점 더 빨라지고, 디스크는 점점 더 커지고 저렴해지고(그러나 빠르지는 않지만), 메모리 크기는 기하급수적으로 증가합니다. 디스크 탐색 시간(탐색 시간이 없는 SSD 제외)은 크게 개선되지 않은 매개변수입니다.

이러한 요소들의 조합은 많은 파일 시스템에서 성능 병목 현상이 발생한다는 것을 의미합니다. 버클리 대학의 연구에서는 새로운 파일 시스템 LFS(로그 구조) 설계를 시도합니다.

**파일 시스템, 로그 구조 파일 시스템)**을 사용하여 이 문제를 완화합니다.

LFS 설계를 주도한 아이디어는 CPU 속도가 증가하고 RAM 메모리가 증가함에 따라 디스크 캐시도 증가한다는 것입니다. 결과적으로 이제 대부분의 읽기 요청은 디스크 액세스 없이 파일 시스템 캐시에서 직접 충족될 수 있습니다. 이러한 관찰에 따르면 미래에는 대부분의 디스크 액세스가 쓰기 작업이 될 것이므로 일부 파일 시스템에서 블록이 필요하기 전에 가져오기 위해 사용되는 미리 읽기 메커니즘은 더 이상 큰 성능을 얻지 못할 것입니다.

설상가상으로 대부분의 파일 시스템에서 쓰기는 매우 작은 단위로 수행됩니다. 소규모 쓰기는 일반적으로 50마이크로초 디스크 쓰기 앞에 10밀리초 탐색 및 4밀리초 회전 지연이 발생하므로 비효율적입니다. 이러한 매개변수를 사용하면 디스크 효율성이 1%로 떨어집니다.

모든 소문자 작업이 어디서 왔는지 확인하려면 UNIX 시스템에서 새 파일을 만드는 것을 고려해 보세요. 이 파일에 쓰려면 디렉터리의 inode, 디렉터리 블록, 파일의 inode 및 파일 자체를 작성해야 합니다. 이러한 쓰기가 지연될 수 있지만 그렇게 하면 쓰기 전에 충돌이 발생하는 경우 파일 시스템이 심각한 일관성 문제에 노출됩니다. 따라서 i-node 쓰기는 일반적으로 즉시 수행됩니다.

이러한 추론에 따라 LFS 설계자는 대부분 작은 무작위 쓰기로 구성된 작업 부하에도 불구하고 디스크의 전체 대역폭을 달성하기 위해 UNIX 파일 시스템을 다시 구현하기로 결정했습니다. 기본 아이디어는 전체 디스크를 하나의 큰 로그로 구성하는 것입니다.

정기적으로 특별한 요구가 있는 경우 메모리에 버퍼링된 모든 보류 중인 쓰기가 세그먼트로 수집되고 로그 끝에 연속 세그먼트로 디스크에 기록됩니다. 따라서 단일 세그먼트에는 inode, 디렉터리 블록 및 데이터 블록이 혼합되어 포함될 수 있습니다. 각 단락은 해당 단락에서 찾을 수 있는 내용을 설명하는 단락 요약으로 시작됩니다. 평균 세그먼트를 약 1MB로 설정할 수 있으면 디스크의 거의 전체 대역폭을 활용할 수 있습니다.

이 설계에서는 i-노드가 여전히 존재하고 UNIX와 동일한 구조를 가지지만 이제 디스크의 고정된 위치가 아닌 로그 전체에 분산되어 있습니다. 그러나 i-노드를 찾을 때 블록은 일반적으로 기존 방식으로 배치됩니다. 물론 i-노드를 찾는 것은 이제 훨씬 더 어렵습니다. 그 이유는 해당 주소가 UNIX에서처럼 i-번호로 간단히 계산될 수 없기 때문입니다. i-노드를 찾을 수 있도록 i 번호로 인덱스된 i-노드 매핑 테이블이 유지됩니다. 이 맵의 항목 i는 디스크의 i 노드 i를 가리킵니다. 매핑 테이블은 디스크에 보관되지만 캐시도 되기 때문에 가장 일반적으로 사용되는 부분은 대부분의 시간 동안 메모리에 있습니다.

지금까지 설명한 내용을 요약하면 모든 쓰기는 처음에 메모리에 버퍼링되고 주기적으로 모든 버퍼링된 쓰기는 로그 끝의 단일 세그먼트로 디스크에 기록됩니다. 이제 파일을 열려면 매핑 테이블을 사용하여 파일의 i-노드를 찾습니다. i-노드를 찾으면 해당 블록의 주소를 찾을 수 있습니다. 모든 청크 자체는 분할되어 로그 어딘가에 위치합니다.

디스크가 무한히 크다면 위의 설명이 전부입니다. 그러나 실제 디스크는 제한되어 있으므로 로그는 결국 전체 디스크를 차지하게 되며, 이 시점에서는 새 세그먼트를 로그에 쓸 수 없습니다. 다행히도 기존 세그먼트에는 더 이상 필요하지 않은 블록이 있을 수 있습니다. 예를 들어, 파일을 덮어쓰면 해당 i-노드는 이제 새 블록을 가리키지만 이전 블록은 이전에 작성된 세그먼트에서 여전히 공간을 차지합니다.

이 문제를 해결하기 위해 LFS에는 로그를 압축하기 위해 루프에서 로그를 검색하는 데 시간을 소비하는 더 깨끗한 스레드가 있습니다. 먼저 로그의 첫 번째 세그먼트 다이제스트를 읽어 그 안에 어떤 inode와 파일이 있는지 확인합니다. 그런 다음 현재 i-노드 맵을 검사하여 i-노드가 여전히 최신 상태이고 파일 블록이 여전히 사용 중인지 확인합니다. 그렇지 않으면 정보가 삭제됩니다. 아직 사용 중인 Inode와 블록은 메모리로 들어가 다음 세그먼트에 기록됩니다. 그런 다음 원래 세그먼트는 사용 가능으로 표시되어 로그에서 새 데이터에 사용할 수 있습니다. 이러한 방식으로 클리너는 로그를 따라 이동하여 뒤에서 오래된 세그먼트를 제거하고 다음 세그먼트에 다시 쓸 수 있도록 모든 라이브 데이터를 메모리에 배치합니다. 따라서 디스크는 쓰기 스레드가 앞쪽에 새 세그먼트를 추가하고 청소 스레드가 뒤쪽에서 이전 세그먼트를 제거하는 큰 원형 버퍼입니다.

파일 블록이 새 세그먼트에 다시 기록될 때 파일의 i-노드(로그 어딘가)를 찾아서 업데이트하고 다음 세그먼트에 기록하려면 메모리로 가져와야 하기 때문에 여기에서 기록이 중요합니다. 그러면 i-노드 맵이 새 복사본을 가리키도록 업데이트되어야 합니다. 그럼에도 불구하고 관리가 용이하며 성능 결과에 따르면 모든 복잡성이 그만한 가치가 있음을 보여줍니다. 위 문서에 제시된 측정 결과에 따르면 LFS는 낮은 쓰기 작업에서는 UNIX보다 몇 배나 우수하고 읽기 작업 및 높은 쓰기 작업에서는 UNIX와 같거나 더 나은 성능을 발휘합니다.

18.11.3.6 로그 파일 시스템

로그 구조 파일 시스템은 흥미로운 아이디어이지만 널리 사용되지는 않습니다. 부분적으로는 기존 파일 시스템과 호환성이 매우 낮기 때문입니다. 그러나 여기에 내재된 한 가지 아이디어, 즉 장애에 대한 견고성은 보다 전통적인 파일 시스템에 쉽게 적용될 수 있습니다. 여기서의 기본 아이디어는 파일 시스템에서 작업을 수행하기 전에 로그를 저장하여 예정된 작업을 실행하기 전에 시스템이 충돌한 경우 시스템을 다시 시작할 때 로그를 보고 충돌 당시 무슨 일이 일어났는지 확인하고 작업을 완료할 수 있도록 하는 것입니다. 이러한 파일 시스템을 저널링 파일 시스템이라고 하며 실제로 사용되고 있습니다. Microsoft의 NTFS 파일 시스템과 Linux ext3 및 ReiserFS 파일 시스템은 모두 저널링을 사용하며 OSX는 저널링 파일 시스템을 옵션으로 제공합니다.

문제의 성격을 이해하려면 자주 발생하는 일반적인 작업인 파일 삭제를 고려하십시오. 이 작업(UNIX)에는 세 단계가 필요합니다.

  1. 디렉토리에서 파일을 삭제합니다.

  2. i-node를 여유 i-node 풀로 해제합니다.

  3. 모든 디스크 블록을 사용 가능한 디스크 블록 풀로 되돌립니다.

Windows에서도 유사한 단계가 필요합니다. 시스템 충돌이 발생하지 않는 경우 이러한 단계를 수행하는 순서는 중요하지 않습니다. 충돌이 발생한 경우 실제로 그렇습니다. 첫 번째 단계가 완료된 후 시스템이 충돌한다고 가정합니다. inode와 파일 블록은 어떤 파일에서도 액세스할 수 없고 재할당도 불가능하며 불확정 상태에 있으므로 사용 가능한 리소스가 줄어듭니다. 두 번째 단계 이후에 충돌이 발생하면 블록만 손실됩니다.

작업 순서가 변경되고 i-노드가 먼저 해제되면 재부팅 후 i-노드가 다시 할당될 수 있지만 이전 디렉토리 항목은 계속해서 이를 가리키므로 잘못된 파일이 지정됩니다. 블록이 먼저 해제된 경우 i-노드가 지워지기 전에 충돌이 발생하면 유효한 디렉토리 항목이 현재 여유 스토리지 풀에 있는 블록을 나열하는 i-노드를 가리키며 곧 재사용될 가능성이 높으므로 두 개 이상의 파일이 동일한 블록을 무작위로 공유하게 됩니다. 이 결과 중 어느 것도 좋지 않습니다.

저널링 파일 시스템이 수행하는 작업은 먼저 완료할 세 가지 작업을 나열하는 로그 항목을 작성하는 것입니다. 그런 다음 로그 항목이 디스크에 기록됩니다. 더 나은 측정을 위해 디스크에서 읽어 실제로 올바르게 기록되었는지 확인할 수 있습니다. 로그 항목이 작성된 후에만 다양한 작업을 시작할 수 있습니다. 작업이 성공적으로 완료되면 로그 항목이 지워집니다. 지금 시스템이 충돌하면 복구 시 파일 시스템에서 로그를 확인하여 보류 중인 작업이 있는지 확인할 수 있습니다. 그렇다면 파일이 제대로 삭제될 때까지 이러한 파일을 모두 다시 실행할 수 있습니다(충돌이 반복되는 경우 여러 번).

로깅이 효과적이려면 로깅된 작업이 멱등적이어야 합니다. 즉, 해를 끼치지 않고 필요에 따라 반복할 수 있어야 합니다. "i-노드 k 또는 블록 n을 사용 가능으로 표시하기 위해 비트맵 업데이트"와 같은 작업은 열 회귀 위험이 없을 때까지 반복될 수 있습니다. 마찬가지로, 디렉토리를 검색하고 foobar라는 항목을 제거하는 것은 멱등적입니다. 반면, i-노드 K에서 새로 해제된 블록을 사용 가능 목록 끝에 추가하는 것은 이미 존재할 수 있으므로 멱등성이 아닙니다. 더 비용이 많이 드는 작업인 "사용 가능한 블록 목록을 검색하고 아직 존재하지 않으면 블록 n을 추가합니다"는 멱등성을 갖습니다. 저널링 파일 시스템은 데이터 구조와 기록 가능한 작업을 모두 멱등성이 있도록 배열해야 합니다. 이러한 상황에서는 응급 복구를 빠르고 안전하게 수행할 수 있습니다.

신뢰성을 높이기 위해 파일 시스템은 원자 트랜잭션이라는 데이터베이스 개념을 도입할 수 있습니다. 이 개념을 사용할 때 작업 집합은 트랜잭션 시작 및 트랜잭션 종료 작업으로 묶일 수 있습니다. 그러면 파일 시스템은 대괄호 안의 모든 작업을 완료해야 하거나 아무것도 완료하지 않고 다른 조합을 완료하지 않아야 함을 인식합니다.

NTFS에는 시스템 충돌로 인해 구조가 거의 손상되지 않는 광범위한 저널링 시스템이 있습니다. Windows NT는 처음 출시된 1993년부터 개발되어 왔습니다. 로깅을 수행하는 최초의 Linux 파일 시스템은 ReiserFS였지만 당시 표준 ext2 파일 시스템과의 비호환성으로 인해 인기가 떨어졌습니다. 대조적으로, ReiserFS에 비해 ext3은 이전 ext2 시스템과의 호환성을 유지하면서 로깅을 수행하는 덜 야심찬 프로젝트입니다.

18.11.3.7 가상 파일 시스템

동일한 운영 체제라도 동일한 컴퓨터에서 다양한 파일 시스템이 사용되는 경우가 많습니다. Windows 시스템에는 기본 NTFS 파일 시스템이 있을 수 있지만 오래되었지만 여전히 필요한 데이터가 포함된 오래된 FAT-32 또는 FAT-16 드라이브나 파티션이 있을 수 있으며 때로는 플래시 드라이브, 오래된 CD-ROM 또는 DVD(각각 고유한 파일 시스템이 있음)가 있을 수 있습니다. Windows는 서로 다른 드라이브 문자(예: C:, D: 등)로 각 파일 시스템을 식별하여 이러한 다양한 파일 시스템을 처리합니다. 프로세스가 파일을 열면 Windows가 요청을 전달할 파일 시스템을 알 수 있도록 드라이브 문자가 명시적으로 또는 암시적으로 표시됩니다. 이기종 파일 시스템을 하나의 통합된 전체로 통합하려는 시도는 없습니다.

대조적으로, 현대의 모든 UNIX 시스템은 여러 파일 시스템을 단일 구조로 통합하려는 진지한 시도를 하고 있습니다. Linux 시스템은 ext2를 루트 파일 시스템으로 사용하고, ext3 파티션을 /usr에 마운트하고, ReiserFS 파일 시스템이 있는 두 번째 하드 드라이브를 /home에 마운트하고, ISO 9660 CD-ROM을 /mnt에 임시 마운트할 수 있습니다. 사용자 관점에서 볼 때 단일 파일 시스템 계층 구조가 있습니다. 여기에는 사용자나 프로세스가 볼 수 없는 여러(호환되지 않는) 파일 시스템이 포함되어 있습니다.

그러나 여러 파일 시스템의 존재는 구현 시 매우 명백하며 Sun Microsystems의 선구적인 작업 이후 대부분의 UNIX 시스템은 VFS(가상 파일 시스템) 개념을 사용하여 여러 파일 시스템을 정렬된 구조로 통합하려고 시도했습니다. 핵심 아이디어는 모든 파일 시스템에 공통적인 파일 시스템 부분을 추상화하고 해당 코드를 기본 구체적인 파일 시스템을 호출하여 실제로 데이터를 관리하는 별도의 레이어에 배치하는 것입니다. 전체적인 구조는 아래 그림과 같습니다. 다음 논의는 Linux나 FreeBSD 또는 다른 UNIX 버전에 국한되지 않고 UNIX 시스템에서 가상 파일 시스템이 작동하는 방식에 대한 소개입니다.

가상 파일 시스템의 위치.

모든 파일 관련 시스템 호출은 초기 처리를 위해 가상 파일 시스템으로 전달됩니다. 사용자 프로세스의 이러한 호출은 열기, 읽기, 쓰기, lseek 등과 같은 표준 POSIX 호출입니다. 따라서 VFS에는 잘 알려진 POSIX 인터페이스인 사용자 프로세스를 위한 "상위" 인터페이스가 있습니다.

VFS에는 위 다이어그램에서 VFS 인터페이스라고 표시된 특정 파일 시스템에 대한 "하위" 인터페이스도 있습니다. 인터페이스는 VFS가 작업을 완료하기 위해 각 파일 시스템에 대해 수행할 수 있는 수십 개의 함수 호출로 구성됩니다. 따라서 VFS와 함께 작동하는 새 파일 시스템을 만들려면 새 파일 시스템의 설계자는 VFS에 필요한 함수 호출을 제공하는지 확인해야 합니다. 이러한 함수의 확실한 예는 디스크에서 특정 블록을 읽고 이를 파일 시스템의 버퍼 캐시에 넣은 다음 블록에 대한 포인터를 반환하는 것입니다. 따라서 VFS에는 두 가지 인터페이스가 있습니다. 상위 인터페이스는 사용자 프로세스용이고 하위 인터페이스는 특정 파일 시스템용입니다.

VFS의 대부분의 파일 시스템은 로컬 디스크의 파티션을 나타내지만 항상 그런 것은 아닙니다. 실제로 VFS를 구축하려는 Sun의 원래 동기는 NFS(네트워크 파일 시스템) 프로토콜을 사용하여 원격 파일 시스템을 지원하는 것이었습니다. VFS의 설계는 특정 파일 시스템이 VFS에 필요한 기능을 제공하는 한 VFS는 데이터가 저장되는 위치나 기본 파일 시스템의 모양을 알거나 신경 쓰지 않도록 설계되었습니다.

내부적으로 대부분의 VFS 구현은 C++가 아닌 C로 작성되었더라도 본질적으로 객체 지향적입니다. 일반적으로 슈퍼 블록(파일 시스템 설명), v-노드(파일 설명) 및 디렉터리(파일 시스템 디렉터리 설명)를 포함하여 여러 주요 개체 유형이 지원되며, 각 유형에는 특정 파일 시스템이 지원해야 하는 관련 작업(메서드)이 있습니다. 또한 VFS에는 사용자 프로세스에서 열려 있는 모든 파일을 추적하는 마운트 테이블과 파일 설명자 세트를 포함하여 자체적으로 사용할 수 있는 일부 내부 데이터 구조가 있습니다.

VFS의 작동 방식을 이해하기 위해 연대순으로 예제를 실행해 보겠습니다. 시스템이 시작되면 루트 파일 시스템이 VFS에 등록됩니다. 또한 부팅 시 또는 작동 중에 다른 파일 시스템이 마운트되는 경우에도 VFS에 등록해야 합니다. 파일 시스템이 등록되면 기본적으로 VFS에 필요한 기능의 주소 목록을 제공합니다. 이는 긴 호출 벡터(테이블)일 수도 있고 VFS에 필요한 각 VFS 개체에 대해 하나씩 여러 개일 수도 있습니다. 따라서 파일 시스템이 VFS에 등록되면 VFS는 파일 시스템에서 블록을 읽는 방법을 알고 파일 시스템에서 제공하는 벡터의 네 번째(또는 다른) 함수를 호출합니다. 마찬가지로 VFS는 특정 파일 시스템이 제공해야 하는 다른 모든 기능을 수행하는 방법을 알고 있습니다. VFS는 파일 시스템이 등록될 때 주소가 제공된 기능만 호출합니다.

파일 시스템이 마운트되면 사용할 준비가 됩니다. 예를 들어, 파일 시스템이 /usr에 마운트되고 프로세스가 다음에 대한 호출을 여는 경우:

cpp
open("/usr/include/unistd.h", O_RDONLY)

VFS는 새 파일 시스템이 /usr에 마운트되었음을 ​​확인하고 마운트된 파일 시스템의 슈퍼블록 목록을 검색하여 해당 슈퍼블록을 찾습니다. 이 작업이 완료되면 마운트된 파일 시스템의 루트를 찾고 거기서 include/unistd.h 경로를 찾을 수 있습니다. 그런 다음 VFS는 v-노드를 생성하고 특정 파일 시스템을 호출하여 파일 inode의 모든 정보를 반환합니다. 이 정보는 다른 정보(가장 중요한 함수 테이블에 대한 포인터)와 함께 v-노드(RAM)에 복사되어 v-노드에서 읽기, 쓰기, 닫기 등과 같은 작업을 호출합니다.

v-노드가 생성된 후 VFS는 호출 프로세스에 대한 파일 설명자 테이블에 항목을 생성하고 새 v-노드를 가리키도록 설정합니다. 마지막으로 VFS는 파일 설명자를 호출자에게 반환하여 파일을 읽고 쓰고 닫는 데 사용할 수 있도록 합니다.

나중에 프로세스가 파일 설명자를 사용하여 읽을 때 VFS는 프로세스 및 파일 설명자 테이블에서 v-노드를 찾고 함수 테이블에 대한 포인터를 따라갑니다. 이 모든 테이블은 요청된 파일이 있는 특정 파일 시스템의 주소입니다. 이제 읽기를 처리하는 함수가 호출되고 특정 파일 시스템의 코드가 개입하여 요청된 청크를 가져옵니다. VFS는 데이터가 로컬 디스크, 네트워크의 원격 파일 시스템, USB 플래시 드라이브 또는 다른 것에서 오는지 알지 못합니다. 관련된 데이터 구조는 아래 그림에 나와 있습니다. 호출자의 프로세스 번호와 파일 디스크립터를 시작으로 특정 파일 시스템의 v 노드, 읽기 함수 포인터, 액세스 함수가 ​​순서대로 위치합니다.

VFS 및 특정 파일 시스템에서 읽는 데 사용되는 데이터 구조 및 코드에 대한 단순화된 보기입니다.

이렇게 하면 새 파일 시스템을 추가하는 것이 상대적으로 간단해집니다. 디자이너는 먼저 VFS에서 예상하는 함수 호출 목록을 얻은 다음 파일 시스템을 작성하여 모든 함수 호출을 제공합니다. 또는 파일 시스템이 이미 존재하는 경우 일반적으로 구체적인 파일 시스템에 대해 하나 이상의 기본 호출을 수행하여 VFS에 필요한 작업을 수행하기 위한 래퍼 함수를 ​​제공해야 합니다.

18.11.3.8 파일 시스템 관리 및 최적화

파일 시스템을 작동시키는 것이 한 가지입니다. 실제 생활에서 효율적이고 강력하게 작동하도록 만드는 것은 완전히 다릅니다. 다음 섹션에서는 디스크 관리와 관련된 몇 가지 문제에 대해 설명합니다.

  • 디스크 공간 최적화

파일은 일반적으로 디스크에 저장되므로 디스크 공간 관리는 파일 시스템 설계자의 주요 관심사입니다. n바이트 파일을 저장하는 데에는 두 가지 일반적인 전략이 있습니다. 즉, 연속 n바이트의 디스크 공간을 할당하거나 파일을 여러(반드시 그럴 필요는 없음) 연속 청크로 분할하는 것입니다. 메모리 관리 시스템의 순수 분할과 페이징 간에도 동일한 균형이 존재합니다.

앞서 살펴보았듯이 파일을 연속된 바이트 시퀀스로 저장하는 데에는 명백한 문제가 있습니다. 즉, 파일이 커지면 디스크로 이동해야 할 수도 있다는 것입니다. 메모리 내 세그먼트를 이동하는 것이 한 디스크 위치에서 다른 디스크 위치로 파일을 이동하는 것에 비해 상대적으로 빠른 작업이라는 점을 제외하면 메모리 내 세그먼트에도 동일한 문제가 있습니다. 따라서 거의 모든 파일 시스템은 파일을 인접할 필요가 없는 고정 크기 블록으로 자릅니다.

가장 먼저 고려해야 할 것은 블록 크기입니다.

파일을 고정 크기 블록에 저장하기로 결정하면 블록 크기가 얼마나 커야 하는지에 대한 의문이 생깁니다. 디스크가 구성되는 방식을 고려할 때 섹터, 트랙 및 실린더는 할당 단위의 확실한 후보입니다(비록 모두 장치에 따라 다르므로 이는 부정적입니다). 페이지 크기도 페이징 시스템의 주요 경쟁자입니다.

블록 크기가 크다는 것은 1바이트 파일이라도 각 파일이 전체 실린더를 차지한다는 의미이며, 작은 파일이 많은 디스크 공간을 낭비한다는 의미이기도 합니다. 반면, 블록 크기가 작다는 것은 대부분의 파일이 여러 블록에 걸쳐 있으므로 파일을 읽는 데 여러 번의 검색 및 회전 지연이 필요하므로 성능이 저하된다는 것을 의미합니다. 따라서 할당 단위가 너무 크면 공간이 낭비됩니다. 너무 작으면 시간을 낭비하게 됩니다.

올바른 선택을 하려면 파일 크기 분포에 대한 몇 가지 정보가 필요합니다. Tanenbaumet al. (2006)은 1984년 대규모 연구 대학(VU)의 컴퓨터 공학과와 2005년 정치 웹사이트(www.electroral-vote.com)를 호스팅하는 상업용 웹 서버에서 파일 크기 분포를 연구했습니다. 결과는 아래 그림에 표시되어 있으며, 두 파일 크기의 각 거듭제곱에 대해 세 데이터 세트 각각에 있는 모든 파일 중 그보다 작거나 같은 비율이 나열되어 있습니다. 예를 들어, 2005년에는 VU에 있는 파일의 59.13%가 4KB 이하였으며, 파일의 90.84%가 64KB 이하였습니다. 중간 파일 크기는 2475바이트입니다. 어떤 사람들은 이 작은 크기가 놀랍다고 생각할 수도 있습니다.

주어진 크기(바이트)보다 작은 파일의 비율입니다.

이 데이터에서 어떤 결론을 내릴 수 있습니까? 첫째, 블록 크기가 1KB인 파일의 경우 파일의 약 30~50%만 블록에 들어갈 수 있는 반면, 4KB 파일 블록의 경우 블록에 맞는 파일의 비율은 60~70%에 달할 수 있습니다. 이 문서의 다른 데이터에 따르면 4KB 블록의 경우 디스크 블록의 93%가 가장 큰 파일의 10%에서 사용됩니다. 이는 디스크가 소수의 대용량 파일(비디오)로 채워져 있고 작은 파일이 차지하는 전체 공간이 거의 관련이 없기 때문에 각 작은 파일의 끝에서 일부 공간을 낭비하는 것은 거의 관련이 없음을 의미합니다. 가장 작은 90%의 파일이 차지하는 공간을 두 배로 늘려도 거의 눈에 띄지 않습니다.

반면에 작은 청크를 사용한다는 것은 각 파일이 여러 청크로 구성된다는 의미입니다. 각 블록을 읽으려면 일반적으로 탐색 및 회전 지연이 필요하므로(반도체 디스크 제외) 많은 작은 블록으로 구성된 파일을 읽는 속도가 느려집니다.

예를 들어, 트랙당 1MB, 회전 시간 8.33ms, 평균 검색 시간 5ms의 디스크를 생각해 보세요. k 바이트의 블록을 읽는 데 걸리는 시간(밀리초)은 탐색 시간, 회전 지연 시간 및 전송 시간의 합입니다.

5+4.165+(k/1000000)×8.33

아래 그림의 점선 곡선은 해당 디스크의 블록 크기에 따른 데이터 속도를 보여줍니다. 공간 효율성을 계산하려면 평균 파일 크기를 가정해야 하며, 단순화를 위해 모든 파일이 4KB라고 가정합니다. 이 숫자는 VU가 측정한 것보다 약간 크지만 학생들은 기업 데이터 센터보다 작은 파일을 더 많이 보유할 가능성이 높으므로 전반적으로 이것이 더 나은 추측일 수 있습니다. 아래 그림의 실선은 블록 크기에 따른 공간 효율성을 보여줍니다.

이 두 곡선은 다음과 같이 이해될 수 있다. 블록의 액세스 시간은 탐색 시간과 회전 지연 시간에 의해 전적으로 결정되므로 블록에 액세스하는 데 9밀리초가 걸린다면 가져올 수 있는 데이터가 많을수록 좋습니다. 따라서 데이터 속도는 블록 크기에 따라 거의 선형적으로 증가합니다(전송 시간이 중요해질 정도로 길어질 때까지).

이제 공간 효율성을 고려해보세요. 4KB 파일과 1KB, 2KB 또는 4KB 블록의 경우 파일은 낭비 없이 각각 4, 2, 1 블록을 사용합니다. 8KB 블록과 4KB 파일의 경우 공간 효율성이 50%로 떨어지고, 16KB 블록의 경우 공간 효율성이 25%로 떨어집니다. 실제로 디스크 블록 크기의 정확한 배수인 파일은 거의 없으므로 파일의 마지막 블록은 항상 일부 공간을 낭비합니다.

그러나 곡선은 성능과 공간 활용 사이에 본질적인 충돌이 있음을 보여줍니다. 작은 데이터 블록은 성능에는 좋지 않지만 디스크 공간 활용에는 좋습니다. 이 수치에는 합리적인 타협이 없습니다. 두 곡선이 교차하는 부분에 가장 가까운 크기는 64KB인데 데이터 전송률은 6.6MB/초에 불과하고 공간 효율성도 7% 정도에 불과해 둘 다 크지 않다. 예전에는 선택하는 파일 시스템 크기가 1KB에서 4KB 사이였지만 이제는 디스크가 1TB를 초과하므로 블록 크기를 64KB로 늘리고 낭비되는 디스크 공간을 수용하는 것이 좋습니다. 디스크 공간은 더 이상 부족하지 않습니다.

Vogels는 Windows NT 파일 사용이 UNIX 파일 사용과 크게 다른지 확인하기 위해 Cornell University에서 파일을 측정했습니다(Vogels, 1999). 그는 NT 파일의 사용이 UNIX에서보다 더 복잡하다는 사실을 발견했습니다. 그는 다음과 같이 썼습니다. 메모장 텍스트 편집기에 문자 몇 개를 입력했을 때 파일에 저장하면 3번의 열기 시도 실패, 1번의 파일 덮어쓰기, 4번의 추가 열기 및 닫기 시퀀스를 포함하여 26번의 시스템 호출이 실행되었습니다.

그러나 Vogels는 중앙값 파일 크기(사용량에 따라 가중치 적용)가 1KB, 쓰기 파일의 경우 2.3KB, 읽기 및 쓰기 파일의 경우 4.2KB임을 확인했습니다. 다양한 데이터 세트 측정 기술과 연도를 고려하면 이러한 결과는 확실히 VU 결과와 호환됩니다.

다음으로 Keeping Track of Free Blocks(Keeping Track of Free Blocks)에 대해 설명하겠습니다.

블록 크기가 선택되면 다음 질문은 사용 가능한 블록을 추적하는 방법입니다. 아래 그림과 같이 널리 사용되는 두 가지 방법이 있습니다. 첫 번째 방법은 가능한 한 많은 디스크 블록 번호를 포함하는 디스크 블록의 연결된 목록을 사용하는 것입니다. 1KB 블록과 32비트 디스크 블록 번호의 경우 사용 가능한 목록의 각 블록에는 255개의 사용 가능한 블록이 포함됩니다. (다음 블록을 가리키는 포인터에는 슬롯이 필요합니다.) 약 10억 개의 디스크 블록이 있는 1TB 디스크를 생각해 보세요. 이 모든 주소를 블록당 255개로 저장하려면 약 400만 개의 블록이 필요합니다. 일반적으로 사용 가능한 블록은 사용 가능한 목록을 보관하는 데 사용되므로 저장소는 본질적으로 무료입니다.

(a) 무료 목록을 연결 목록에 저장합니다. (b) 비트맵.

사용 가능한 또 다른 공간 관리 기술은 비트맵입니다. n개의 블록이 있는 디스크에는 n비트의 비트맵이 필요합니다. 다이어그램에서 사용 가능한 블록은 1로 표시되고 할당된 블록은 0으로 표시됩니다(그 반대도 마찬가지). 예제 1TB 디스크의 경우 매핑에는 10억 비트가 필요하며 이를 저장하려면 약 130,000개의 1KB 블록이 필요합니다. 연결된 목록 모델에서 사용되는 32비트에 비해 비트맵은 블록당 1비트를 사용하므로 비트맵이 더 적은 공간을 필요로 한다는 것은 놀라운 일이 아닙니다. 디스크가 거의 가득 찼을 때만(즉, 사용 가능한 블록이 거의 없는 경우) 연결 목록 구성표에는 비트맵보다 더 적은 수의 블록이 필요합니다.

사용 가능한 블록이 긴 연속 블록에서 발생하는 경향이 있는 경우 사용 가능한 목록 시스템을 수정하여 개별 블록이 아닌 블록의 실행을 추적할 수 있습니다. 8, 16 또는 32비트 카운트는 각 블록과 연결되어 연속적으로 사용 가능한 블록의 수를 제공할 수 있습니다. 가장 좋은 경우, 대부분 비어 있는 디스크는 두 개의 숫자, 즉 첫 번째 사용 가능한 블록의 주소와 사용 가능한 블록 수로 표시될 수 있습니다. 반면에 디스크가 심하게 조각난 경우 주소뿐만 아니라 개수도 저장해야 하기 때문에 추적 실행은 개별 블록을 추적하는 것보다 효율성이 떨어집니다.

이 질문은 운영 체제 디자이너가 자주 직면하는 문제를 보여줍니다. 문제를 해결하는 데 사용할 수 있는 데이터 구조와 알고리즘은 다양하지만, 최상의 데이터 구조와 방법을 선택하려면 시스템이 배포되어 많이 사용될 때까지 설계자가 갖고 있지도 않고 갖지도 않을 데이터가 필요합니다. 그럼에도 불구하고 데이터를 사용하지 못할 수도 있습니다. 예를 들어 1984년과 1995년에 측정된 VU 파일 크기, 웹사이트 데이터, 코넬대학교 데이터는 단지 4개의 샘플에 불과합니다. 없는 것보다는 훨씬 낫지만 가정용 컴퓨터, 회사 컴퓨터, 정부 컴퓨터 및 기타 컴퓨터를 나타낼 수도 있는지는 모르겠습니다. 약간의 노력을 기울이면 다른 유형의 컴퓨터에서 일부 샘플을 얻을 수 있었을 수도 있지만, 그렇다고 하더라도 이러한 샘플을 측정 중인 모든 컴퓨터에 추정하는 것은 어리석은 일입니다.

free list 방법으로 돌아가면, 메인 메모리에 포인터만 유지하면 됩니다. 파일이 생성되면 포인터 블록에서 필요한 블록을 가져옵니다. 메모리가 부족해지면 디스크에서 새 포인터 블록을 읽습니다. 마찬가지로, 파일이 삭제되면 해당 블록이 해제되고 주 메모리의 포인터 블록에 추가됩니다. 이 블록이 채워지면 디스크에 기록됩니다.

어떤 경우에는 이 방법으로 인해 불필요한 디스크 I/O가 발생할 수 있습니다. 메모리의 포인터 블록이 두 개의 항목만 더 보유할 수 있는 아래 그림 (a)의 상황을 생각해 보십시오. 3블록 파일이 해제되면 포인터 블록이 오버플로되어 디스크에 기록되어야 하므로 (b)와 같은 상황이 발생합니다. 이제 3블록 파일이 작성되면 전체 포인터 블록을 다시 읽어야 하며 (a)로 돌아갑니다. 방금 쓴 세 블록이 임시 파일인 경우 파일이 해제될 때 전체 포인터 블록을 디스크에 다시 쓰려면 또 다른 디스크 쓰기가 필요합니다. 즉, 포인터 블록이 거의 비어 있을 때 일련의 단기 임시 파일로 인해 많은 디스크 I/O가 발생할 수 있습니다.

대부분의 디스크 I/O를 방지하는 또 다른 방법은 전체 포인터 블록을 분할하는 것입니다. 따라서 세 개의 블록이 해제되면 더 이상 이미지 (a)에서 아래 이미지로 이동하지 않습니다. 이제 시스템은 디스크 I/O를 수행하지 않고도 일련의 임시 파일을 처리할 수 있습니다. 메모리의 블록이 가득 차면 디스크에 기록되고 디스크에서 절반이 채워진 블록을 읽습니다. 여기서 아이디어는 디스크의 포인터 블록 대부분을 가득 채운 상태로 유지하지만(디스크 사용량을 최소화하기 위해) 메모리의 포인터 블록을 절반 정도 채워서 사용 가능 목록에서 디스크 I/O 없이 파일 생성 및 파일 삭제를 처리할 수 있도록 하는 것입니다.

(a) 메모리의 디스크 블록을 해제하기 위한 거의 완전한 포인터 블록과 디스크의 포인터 블록 3개입니다. (b) 세 개의 블록 파일을 해제한 결과. (c) 3개의 자유 블록을 처리하기 위한 대체 전략. 음영 처리된 항목은 사용 가능한 디스크 블록에 대한 포인터를 나타냅니다.

비트맵을 사용하면 블록을 메모리에 유지하고 블록이 완전히 차거나 비어 있을 때만 디스크에 다른 블록을 넣을 수도 있습니다. 이 접근 방식의 또 다른 이점은 비트맵의 단일 블록에서 모든 할당을 수행함으로써 디스크 블록이 밀접하게 연결되어 디스크 암 이동을 최소화한다는 것입니다. 비트맵은 고정 크기 데이터 구조이므로 커널이 (부분적으로) 페이징된 경우 비트맵을 가상 메모리에 배치하고 필요에 따라 페이징할 수 있습니다.

다음으로 디스크 할당량에 대해 설명하겠습니다.

사람들이 너무 많은 디스크 공간을 차지하는 것을 방지하기 위해 다중 사용자 운영 체제는 종종 디스크 할당량을 적용하는 메커니즘을 제공합니다. 시스템 관리자는 각 사용자에게 최대 파일 및 블록 할당량을 할당하고 운영 체제는 사용자가 할당량을 초과하지 않도록 보장한다는 개념입니다. 일반적인 메커니즘은 아래에 설명되어 있습니다.

사용자가 파일을 열면 속성과 디스크 주소가 찾아서 주 메모리의 열린 파일 테이블에 배치됩니다. 소유자가 누구인지 알려주는 항목이 속성에 있으며, 파일 크기가 증가하면 소유자 할당량에 포함됩니다.

두 번째 테이블에는 다른 사람이 파일을 연 경우에도 현재 파일을 열고 있는 각 사용자에 대한 할당량 레코드가 포함되어 있습니다. 이 표는 아래와 같습니다. 현재 파일을 열고 있는 사용자에 대한 디스크의 할당량 파일에서 추출되며, 모든 파일을 닫은 후 기록이 다시 할당량 파일에 기록됩니다.

열린 파일 테이블에 새 항목이 생성되면 다양한 제한 사항을 쉽게 찾을 수 있도록 소유자의 할당량 레코드에 대한 포인터가 해당 항목에 입력됩니다. 블록이 파일에 추가될 때마다 소유자에게 청구되는 총 블록 수가 증가하고 하드 및 소프트 제한이 확인됩니다. 소프트 제한은 초과할 수 있지만 하드 제한은 초과할 수 없습니다. 하드 블록 제한에 도달한 경우 파일에 추가하려고 하면 오류가 발생합니다. 사용자가 모든 i-노드를 차지하지 못하도록 유사한 파일 수 검사가 존재합니다.

사용자가 로그인을 시도하면 할당량 파일을 검사하여 사용자가 파일 또는 디스크 블록 수에 대한 소프트 제한을 초과했는지 확인합니다. 한도를 위반하면 경고가 표시되고 남은 경고 수가 1개 감소합니다. 개수가 0이면 사용자는 경고를 여러 번 무시하고 로그인이 허용되지 않습니다. 다시 로그인할 수 있는 권한을 얻으려면 시스템 관리자와 논의해야 합니다.

이 방법에는 사용자가 로그아웃하기 전에 초과된 제한을 제거한 경우 로그인 세션 중에 소프트 제한을 초과할 수 있는 속성이 있습니다. 하드 제한을 초과하면 안 됩니다.

  • 파일 시스템 백업

파일 시스템의 손상은 컴퓨터 손상보다 더 큰 경우가 많습니다. 화재, 번개 또는 키보드에 커피 한 잔이 쏟아져 컴퓨터가 파손되면 짜증나고 비용도 많이 들지만 일반적으로 번거로움을 최소화하면서 교체품을 구입할 수 있습니다. 저렴한 PC는 컴퓨터 매장에 가면 한 시간 안에 교체할 ​​수도 있습니다.

하드웨어나 소프트웨어로 인해 컴퓨터의 파일 시스템이 복구 불가능하게 손실된 경우 모든 정보를 복구하는 것은 어렵고 시간이 많이 걸리며 대부분의 경우 불가능합니다. 프로그램, 문서, 세금 기록, 고객 파일, 데이터베이스, 마케팅 계획 또는 기타 데이터가 영원히 손실된 경우 그 결과는 재앙적일 수 있습니다. 파일 시스템은 장치와 미디어의 물리적 파괴에 대한 보호를 제공할 수 없지만 매우 간단하게 백업을 만들어 정보를 보호할 수 있지만 이는 말처럼 간단하지 않습니다.

대부분의 사람들은 어느 날 디스크가 갑자기 손상되어 대부분의 사람들이 치명적인 변환을 경험하기 전까지는 파일을 백업하는 데 시간과 노력을 들일 가치가 없다고 생각합니다. 그러나 기업은 일반적으로 데이터의 가치를 잘 인식하고 있으며 일반적으로 하루에 한 번 이상, 테이프에 백업하는 경우가 많습니다. 최신 테이프는 수백 기가바이트를 저장할 수 있으며 기가바이트당 비용은 아주 저렴합니다. 그러나 백업은 말처럼 간단하지 않으므로 아래에서 몇 가지 관련 문제를 살펴보겠습니다. 테이프 백업은 일반적으로 다음 두 가지 잠재적인 문제 중 하나를 처리하는 데 사용됩니다.

  1. 재해로부터 복구합니다.

  2. 어리석음에서 벗어나십시오.

첫 번째는 디스크 충돌, 화재, 홍수 또는 기타 자연 재해 후에 컴퓨터를 다시 실행하는 것입니다. 실제로 이러한 일은 자주 발생하지 않으므로 많은 사람들이 백업에 신경 쓰지 않습니다.

두 번째 이유는 사용자가 나중에 다시 필요한 파일을 실수로 삭제하는 경우가 많기 때문입니다. 이 문제는 Windows에서 파일을 "삭제"할 때 자주 발생합니다. 전혀 삭제되지 않고 나중에 쉽게 추출하고 복원할 수 있도록 특수 디렉터리인 휴지통으로 이동하기만 하면 됩니다. 백업은 며칠 또는 몇 주 전에 삭제된 파일을 오래된 백업 테이프에서 복구할 수 있도록 함으로써 이 원칙을 한 단계 더 발전시킵니다.

백업은 시간도 오래 걸리고 공간도 많이 차지하므로 효율적이고 편리하게 백업하는 것이 중요합니다. 이러한 고려 사항은 다음과 같은 질문을 제기합니다.

첫째, 전체 파일 시스템을 백업해야 합니까, 아니면 일부만 백업해야 합니까? 많은 설치에서 실행 가능한(바이너리) 프로그램은 파일 시스템 트리의 제한된 부분에 보관됩니다. 제조업체의 웹 사이트나 설치 DVD에서 다시 설치할 수 있는 경우에는 이러한 파일을 백업할 필요가 없습니다. 또한 대부분의 시스템에는 임시 파일용 디렉터리가 있으며 일반적으로 이를 지원할 이유가 없습니다. UNIX에서는 모든 특수 파일(I/O 장치)이 /dev 디렉토리에 보관됩니다. 이 디렉터리를 백업할 필요가 없을 뿐만 아니라, 백업 프로그램이 완료될 때까지 모든 디렉터리를 읽으려고 하면 영원히 중단되기 때문에 매우 위험합니다. 즉, 전체 파일 시스템을 백업하는 것이 아니라 특정 디렉터리와 그 안에 있는 모든 내용만 백업하는 것이 일반적입니다.

둘째, 마지막 백업 이후 변경되지 않은 파일을 백업하는 것은 낭비이므로 증분 덤프라는 아이디어가 있습니다. 증분 덤프의 가장 간단한 형태는 매주 또는 매월 등 정기적인 간격으로 전체 덤프(백업)를 수행하고 매일 마지막 전체 덤프 이후 수정된 파일만 덤프하는 것입니다. 더 나은 접근 방식은 마지막 덤프 이후 변경된 파일만 덤프하는 것입니다. 이 시나리오는 덤프 시간을 최소화하지만 가장 최근의 전체 덤프를 먼저 복구한 다음 모든 증분 덤프를 역순으로 복구해야 하므로 복구가 더 복잡해집니다. 복구를 용이하게 하기 위해 더 복잡한 증분 덤프 구성표가 사용되는 경우가 많습니다.

셋째, 일반적으로 많은 양의 데이터가 덤프되므로 데이터를 테이프에 쓰기 전에 압축하는 것이 가장 좋습니다. 그러나 많은 압축 알고리즘을 사용하면 백업 테이프의 단일 불량 지점으로 인해 압축 해제 알고리즘이 손상되어 전체 파일 또는 전체 테이프를 읽을 수 없게 될 수 있습니다. 따라서 백업 스트림을 압축하기로 한 결정은 신중하게 고려해야 합니다.

넷째, 활성 파일 시스템에서는 백업을 수행하기가 어렵습니다. 덤프 프로세스 중에 파일과 디렉터리가 추가, 삭제 및 수정되면 결과 덤프가 일관되지 않을 수 있습니다. 그러나 덤프를 수행하는 데 몇 시간이 걸릴 수 있으므로 백업을 위해 밤새도록 시스템을 오프라인 상태로 유지해야 할 수도 있지만 이는 항상 허용되는 것은 아닙니다. 이를 위해 알고리즘은 주요 데이터 구조를 복사하여 파일 시스템 상태를 빠르게 스냅샷한 다음 블록을 제자리에 업데이트하는 대신 블록을 복사하기 위해 파일 및 디렉터리에 대한 향후 변경을 요구하도록 설계되었습니다(Hutchinson et al., 1999). 이렇게 하면 스냅샷 시점에 파일 시스템이 효과적으로 고정되므로 나중에 여유가 있을 때 백업할 수 있습니다.

다섯째, 마지막으로 백업은 조직에 많은 비기술적인 문제를 일으킬 수 있습니다. 시스템 관리자가 사무실에 모든 백업 디스크나 테이프를 보관하고 커피를 마시러 복도를 걸어가는 동안 이를 열어 놓고 방치한다면 세계 최고의 온라인 보안 시스템은 무용지물이 될 수 있습니다. 스파이가 해야 할 일은 잠시 바지선에 들어가서 주머니에 작은 디스크나 테이프를 넣고 행복하게 빠져나가는 것뿐입니다. 또한, 컴퓨터를 파괴한 화재로 인해 백업 디스크도 모두 파괴된 경우 매일 백업하는 것은 거의 소용이 없습니다. 따라서 백업 디스크를 오프사이트에 배치해야 하지만 이로 인해 더 많은 보안 위험이 발생합니다(이제 두 사이트를 모두 보호해야 하므로). 아래에서는 파일 시스템 전용 백업과 관련된 기술적 문제에 대해 설명합니다.

디스크를 백업 디스크로 덤프하는 데 물리적 덤프 또는 논리적 덤프라는 두 가지 전략을 사용할 수 있습니다.

물리적 덤프는 디스크의 블록 0에서 시작하여 모든 디스크 블록을 출력 디스크에 순서대로 쓰고 마지막 디스크 블록이 복사된 후에 중지됩니다. 이러한 프로그램은 매우 간단하여 다른 어떤 유용한 프로그램도 할 수 없는 방식으로 100% 버그가 없을 수 있습니다.

그럼에도 불구하고 물리적 덤프에 대해 몇 가지 의견을 제시하는 것은 가치가 있습니다. 첫째, 사용되지 않는 디스크 블록을 백업하는 것은 가치가 없습니다. 덤프 프로그램이 사용 가능한 블록 데이터 구조에 액세스할 수 있는 경우 사용되지 않은 블록을 덤프하는 것을 피할 수 있습니다. 그러나 사용되지 않는 블록을 건너뛰려면 백업의 블록 k가 더 이상 디스크의 블록 k가 아니기 때문에 블록(또는 이에 상응하는 것) 앞에 각 블록의 번호를 써야 합니다.

두 번째 문제는 불량 블록을 버리는 것입니다. 결함 없이 큰 디스크를 만드는 것은 거의 불가능하며, 항상 불량 블록이 존재합니다. 때로는 로우 레벨 포맷이 완료되면 불량 블록이 감지되어 불량으로 표시된 다음 이러한 긴급 상황을 위해 각 트랙 끝에 예약된 예비 블록으로 교체됩니다. 많은 경우 디스크 컨트롤러는 운영 체제가 인지하지 못한 채 불량 블록 교체를 투명하게 처리합니다.

그러나 포맷 후 블록 성능이 저하되는 경우가 있으며, 이 경우 운영 체제에서 결국 이를 감지합니다. 일반적으로 모든 불량 블록을 포함하는 "파일"을 생성하여 해당 블록이 여유 블록 풀에 나타나지 않고 할당되지 않도록 함으로써 이 문제를 해결합니다. 말할 필요도 없이 이 파일은 전혀 읽을 수 없습니다.

방금 설명한 대로 모든 불량 블록이 디스크 컨트롤러에 의해 다시 매핑되고 운영 체제에서 숨겨지면 물리적 덤프가 제대로 작동할 수 있습니다. 반면 운영 체제에 표시되고 하나 이상의 불량 블록 파일이나 비트맵에 저장된 경우 물리적 덤프 프로그램은 불량 블록 파일을 백업하려고 할 때 끝없는 디스크 읽기 오류를 방지하기 위해 이 정보에 액세스하고 덤프를 피할 수 있어야 합니다.

Windows 시스템에는 복원 시 필요하지 않은 페이징 및 최대 절전 모드 파일이 있으므로 이러한 파일을 먼저 백업해서는 안 됩니다. 특정 시스템에는 백업하면 안 되는 다른 내부 파일이 있을 수도 있으므로 덤프 프로그램은 이러한 파일에 대해 알아야 합니다.

물리적 덤프의 주요 장점은 단순성과 속도(기본적으로 디스크 속도로 실행)입니다. 가장 큰 단점은 선택한 디렉터리를 건너뛰고, 증분 덤프를 만들고, 요청 시 개별 파일을 복원할 수 없다는 것입니다. 이러한 이유로 대부분의 설치에서는 논리 덤프를 수행합니다.

논리 덤프는 하나 이상의 지정된 디렉터리에서 시작하여 지정된 기준 날짜 이후 변경된 모든 파일과 디렉터리를 반복적으로 덤프합니다(예: 마지막 백업의 증분 덤프 또는 시스템 설치의 전체 덤프). 따라서 논리적 덤프에서 덤프 디스크는 신중하게 식별된 일련의 디렉터리와 파일을 가져오므로 요청 시 특정 파일이나 디렉터리를 쉽게 복원할 수 있습니다.

논리적 덤프는 가장 일반적인 형태이므로 아래 이미지의 예를 사용하여 일반적인 알고리즘을 자세히 살펴보겠습니다. 대부분의 UNIX 시스템은 이 알고리즘을 사용합니다. 다이어그램에는 디렉터리(사각형)와 파일(원)이 포함된 파일 트리가 있습니다. 섀도우 항목은 기준 날짜 이후 수정되었으므로 덤프가 필요합니다. 그림자가 없는 것들은 덤핑이 필요하지 않습니다.

덤프할 파일 시스템입니다. 사각형은 디렉토리이고 원은 파일입니다. 마지막 덤프 이후 섀도우 항목이 수정되었습니다. 각 디렉토리와 파일은 i-노드 번호로 레이블이 지정됩니다.

이 알고리즘은 또한 두 가지 이유로 수정된 파일 또는 디렉토리의 경로에 있는 모든 디렉토리(수정되지 않은 디렉토리 포함)를 수정된 파일 또는 디렉토리로 덤프합니다. 첫 번째 이유는 덤프된 파일과 디렉터리를 다른 컴퓨터의 새 파일 시스템으로 복원할 수 있다는 것입니다. 이러한 방식으로 덤프 및 복원 프로그램을 사용하여 컴퓨터 간에 전체 파일 시스템을 전송할 수 있습니다.

수정된 파일 위에 수정되지 않은 디렉터리를 덤프하는 두 번째 이유는 개별 파일을 점진적으로 복원할 수 있기 때문입니다(아마 어리석은 복원을 처리하기 위해). 전체 파일 시스템 덤프가 일요일 밤에 수행되고 증분 덤프가 월요일 밤에 수행되었다고 가정합니다. 화요일에 /usr/jhs/proj/nr3 디렉토리와 그 아래의 모든 디렉토리 및 파일이 삭제됩니다. 수요일 아침에 사용자가 /usr/jhs/proj/nr3/plans/summary 파일을 복원하려고 한다고 가정합니다. 하지만 파일 다이제스트만 넣을 곳이 없기 때문에 복구하는 것은 불가능합니다. nr3 디렉토리와 계획을 먼저 복원해야 합니다. 소유자, 모드, 시간 등에 대한 정보를 얻으려면 이러한 디렉터리가 마지막 전체 덤프 이후 수정되지 않았더라도 덤프 디스크에 있어야 합니다.

덤프 알고리즘은 i-노드당 여러 비트로 i-노드 번호로 색인화된 비트맵을 유지 관리합니다. 알고리즘이 진행됨에 따라 이 맵에서 비트가 설정되고 지워집니다. 알고리즘은 4단계로 작동됩니다. 1단계는 시작 디렉터리(이 예에서는 루트 디렉터리)에서 시작하여 그 안의 모든 항목을 확인합니다. 수정된 각 파일에 대해 해당 i-노드가 비트맵에 표시됩니다. 각 디렉터리도 표시되어(수정 여부에 관계없이) 반복적으로 확인됩니다.

1단계가 끝나면 수정된 모든 파일과 모든 디렉터리가 아래 (a)에 표시된 대로(음영으로) 비트맵에 표시되었습니다. 2단계에서는 개념적으로 트리를 다시 재귀적으로 탐색하여 수정되지 않은 디렉터리 내 또는 디렉터리 아래의 모든 파일이나 디렉터리의 표시를 해제합니다. 이 단계에서는 (b)와 같이 비트맵이 그대로 유지됩니다. 디렉토리 10, 11, 14, 27, 29 및 30은 이제 그 아래에 수정된 내용이 없기 때문에 표시가 해제되었습니다. 그들은 버려지지 않을 것이다. 이와 대조적으로 디렉토리 5와 6은 자체적으로 수정되지 않았더라도 오늘의 변경 사항을 새 시스템으로 되돌리는 데 필요하기 때문에 덤프됩니다. 효율성을 높이기 위해 1단계와 2단계를 하나의 나무 산책으로 결합할 수 있습니다.

이 시점에서 (b)에 표시된 대로 어떤 디렉터리와 파일을 덤프해야 하는지 알 수 있습니다. 3단계는 i-노드를 번호순으로 검색하고 덤프용으로 표시된 모든 디렉터리를 덤프하는 것으로 구성됩니다. (c)에 표시된 것처럼. 각 디렉터리에는 복원할 수 있도록 디렉터리 속성(소유자, 시간 등)이 접두어로 붙습니다. 마지막으로 4단계에서는 (d)에 표시된 파일도 덤프되고 해당 속성 앞에 다시 붙습니다. 이것으로 덤프가 완료됩니다.

논리 덤프 알고리즘에서 사용되는 비트맵입니다.

덤프 디스크에서 파일 시스템을 복원하는 것은 매우 간단합니다. 먼저 디스크에 빈 파일 시스템을 생성한 다음 가장 최근의 전체 덤프를 복원합니다. 디렉토리는 덤프 디스크에 먼저 나타나기 때문에 모두 먼저 복원되어 파일 시스템의 뼈대를 제공합니다. 그런 다음 파일 자체가 복원되고 프로세스가 반복되어 전체 덤프 후 첫 번째 증분 덤프를 가져온 다음 다음 등을 수행합니다.

논리적 덤핑은 간단하지만 몇 가지 까다로운 문제가 있습니다. 첫째, 프리 블록리스트는 파일이 아니기 때문에 덤프되지 않으므로 모든 덤프를 복원한 후 처음부터 다시 작성해야 합니다. 무료 블록 세트는 모든 병합 파일에 포함된 블록 세트에만 추가되므로 이는 항상 가능합니다.

또 다른 문제는 링크입니다. 파일이 두 개 이상의 디렉터리에 연결된 경우 파일이 한 번만 복원되고 해당 파일을 가리키는 모든 디렉터리가 복원되는 것이 중요합니다.

또 다른 문제는 UNIX 파일에 취약점이 포함될 수 있다는 것입니다. 합법적인 접근 방식은 파일을 열고 몇 바이트를 쓴 다음 먼 파일 오프셋을 찾아 몇 바이트를 더 쓰는 것입니다. 중간 블록은 파일의 일부가 아니므로 덤프하거나 복원해서는 안 됩니다. 코어 파일에는 일반적으로 데이터 세그먼트와 스택 사이에 수백 메가바이트의 공간이 있습니다. 제대로 처리되지 않으면 복구된 각 코어 파일은 이 영역을 0으로 채우므로 가상 주소 공간과 크기가 동일해집니다(예: 2^{32}바이트 또는 더 나쁘게는 2^{64}바이트).

마지막으로 특수 파일, 명명된 파이프 등(실제 파일이 아닌 모든 것)은 어떤 디렉터리에 나타나든 덤프해서는 안 됩니다(/dev로 제한할 필요는 없습니다).

  • 파일 시스템 일관성

또 다른 안정성 문제는 파일 시스템 일관성입니다. 많은 파일 시스템은 블록을 읽고 수정한 다음 기록합니다. **수정된 블록이 모두 기록되기 전에 시스템이 충돌하면 파일 시스템이 일관되지 않은 상태로 남을 수 있습니다.**이 문제는 기록되지 않은 블록 중 일부가 i-노드 블록, 디렉토리 블록 또는 사용 가능한 목록이 포함된 블록인 경우 특히 중요합니다.

일관되지 않은 파일 시스템을 처리하기 위해 대부분의 컴퓨터에는 파일 시스템의 일관성을 확인하는 유틸리티가 있습니다. 예를 들어 UNIX에는 fsck가 있고 Windows에는 sfc(및 기타)가 있습니다. 이 유틸리티는 시스템 시작 시, 특히 충돌 후 실행할 수 있습니다.

다음 설명에서는 fsck의 작동 방식을 설명합니다. Sfc는 다른 파일 시스템에서 작동한다는 점에서 약간 다르지만 이를 수정하기 위해 파일 시스템 고유의 중복성을 사용하는 일반적인 원칙은 여전히 ​​유지됩니다. 모든 파일 시스템 검사기는 각 파일 시스템을 다른 파일 시스템(디스크 파티션)과 독립적으로 확인합니다. 블록과 파일이라는 두 가지 일관성 검사가 가능합니다. 블록 일관성을 확인하기 위해 프로그램은 각 블록에 대한 카운터를 포함하는 두 개의 테이블을 작성하며 처음에는 0으로 설정됩니다. 첫 번째 테이블의 카운터는 각 블록이 파일에 나타나는 횟수를 추적합니다. 두 번째 테이블의 카운터는 각 블록이 사용 가능한 목록(또는 사용 가능한 블록의 비트맵)에 나타나는 빈도를 기록합니다.

그런 다음 프로그램은 파일 구조를 무시하고 0부터 시작하는 모든 디스크 블록을 반환하는 원시 장치를 사용하여 모든 i-노드를 읽습니다. 인덱스 노드부터 시작하여 해당 파일에 사용된 모든 블록 번호 목록을 구성할 수 있습니다. 각 블록 번호를 읽을 때 첫 번째 테이블의 카운터가 증가합니다. 그런 다음 프로그램은 사용 가능한 블록이나 비트맵을 확인하여 사용되지 않은 블록을 찾습니다. 사용 가능 목록에서 블록이 발생할 때마다 두 번째 테이블의 카운터가 증가합니다.

파일 시스템이 일관적이라면 시스템 (a)에 표시된 것처럼 각 블록은 첫 번째 테이블이나 두 번째 테이블에서 1을 갖게 됩니다. 그러나 충돌로 인해 테이블은 그림 (b)와 같이 보일 수 있으며 여기서 상자 2는 두 테이블 모두에 표시되지 않습니다. 누락된 블록으로 보고됩니다. 블록이 누락되어도 실제 피해는 없지만 공간이 낭비되어 디스크 용량이 줄어듭니다. 누락된 블록에 대한 해결책은 간단합니다. 파일 시스템 검사기는 해당 블록을 여유 목록에 추가하기만 하면 됩니다.

또 다른 가능한 상황은 그림 (c)에 나와 있는데, 여기에는 사용 가능 목록에 두 번 나타나는 블록 번호 4가 있습니다. (중복은 사용 가능한 목록이 실제로 목록인 경우에만 발생합니다. 비트맵으로는 불가능합니다.) 해결 방법도 간단합니다. 사용 가능한 목록을 다시 작성하는 것입니다.

일어날 수 있는 최악의 상황은 그림 (d)와 블록 5에서 볼 수 있듯이 동일한 데이터 블록이 두 개 이상의 파일에 존재하는 것입니다. 두 파일 중 하나가 삭제되면 블록 5가 사용 가능 목록에 배치되어 동일한 블록이 사용 중이면서 동시에 사용 가능해지게 됩니다. 두 파일을 모두 삭제하면 해당 블록은 사용 가능 목록에 두 번 추가됩니다.

파일 시스템 상태. (a) 일관성. (b) 누락된 블록. (c) 프리리스트에 중복된 블록이 존재합니다. (d) 중복된 데이터 블록.

파일 시스템 검사기가 취해야 할 적절한 조치는 사용 가능한 블록을 할당하고 블록 5의 내용을 여기에 복사한 다음 복사본을 파일 중 하나에 삽입하는 것입니다. 이렇게 하면 파일의 정보 내용은 동일하게 유지되지만(거의 확실히 횡설수설임에도 불구하고) 파일 시스템 구조는 최소한 일관성을 유지합니다.

사용자가 손상을 확인할 수 있도록 오류를 보고해야 합니다. 파일 시스템 검사기는 각 블록이 올바르게 해석되었는지 확인하는 것 외에도 디렉토리 시스템도 검사합니다. 또한 카운터 테이블을 사용하지만 이러한 카운터는 블록이 아닌 파일별로 계산됩니다. 루트 디렉터리에서 시작하여 트리를 반복적으로 내려가며 파일 시스템의 모든 디렉터리를 확인합니다. 각 디렉터리의 각 i-노드에 대해 해당 파일의 사용 횟수에 대한 카운터가 증가합니다. 하드 링크로 인해 파일이 두 개 이상의 디렉토리에 나타날 수 있다는 점을 명심하십시오. 심볼릭 링크는 계산되지 않으며 대상 파일의 카운터가 증가하지 않습니다.

검사기가 모두 완료되면 i-노드 번호로 색인화된 목록이 표시되어 각 파일에 포함된 디렉터리 수를 알려줍니다. 그런 다음 이 숫자를 i-노드 자체에 저장된 링크 수와 비교합니다. 이 개수는 파일이 생성될 때 1부터 시작하고 파일에 대한 (하드) 링크가 만들어질 때마다 증가됩니다. 일관된 파일 시스템에서는 이 두 개수가 일관됩니다. 그러나 두 가지 오류가 발생할 수 있습니다. i-노드의 링크 수가 너무 높거나 너무 낮을 수 있습니다.

링크 개수가 디렉터리 항목 수보다 크면 디렉터리에서 모든 파일이 제거되더라도 개수는 여전히 0이 아니며 i-노드가 삭제되지 않습니다. 이 오류는 심각하지는 않지만 파일이 디렉터리에 없으면 디스크 공간을 낭비합니다. 이는 i-노드의 링크 수를 올바른 값으로 설정하여 해결해야 합니다.

**또 다른 실수는 재앙이 될 수 있습니다.**두 개의 디렉토리 항목이 파일에 연결되어 있지만 i-노드가 하나만 나타내는 경우, 디렉토리 항목 중 하나가 삭제되면 i-노드 수가 0이 됩니다. i-노드 수가 0에 도달하면 파일 시스템은 이를 사용되지 않은 것으로 표시하고 모든 블록을 해제합니다. 이 작업을 수행하면 이제 디렉터리 중 하나가 사용되지 않는 i-노드를 가리키게 되며, 해당 블록의 블록은 곧 다른 파일에 할당될 수 있습니다. 다시 말하지만, 해결책은 i-노드의 링크 수를 실제 디렉토리 항목 수로 강제하는 것입니다.

효율성을 위해 이 두 가지 작업(블록 확인 및 디렉터리 확인)은 일반적으로 통합됩니다(즉, i-노드에서 한 번의 패스만 필요함). 다른 테스트도 수행될 수 있습니다. 예를 들어, 디렉토리는 i-노드 번호와 ASCII 이름을 포함하는 명확한 형식을 갖습니다. i-노드 수가 디스크에 있는 i-노드 수보다 크면 디렉터리가 손상됩니다.

또한 각 i-노드에는 모드가 있는데, 그 중 일부는 합법적이지만 이상한 모드입니다. 예를 들어 0007은 소유자와 그의 그룹이 전혀 액세스할 수 없도록 허용하지만 외부인은 파일을 읽고, 쓰고, 실행할 수 있습니다. 적어도 소유자보다 외부인에게 더 많은 권리를 부여하는 문서를 신고하는 것이 유용할 수 있습니다. 예를 들어 항목이 1,000개가 넘는 디렉터리도 의심스럽습니다. 사용자 디렉터리에 있지만 슈퍼유저가 소유하고 SETUID 비트가 활성화된 파일은 모든 사용자가 실행할 때 슈퍼유저 권한을 얻게 되므로 보안 문제가 발생할 수 있습니다. 조금만 노력하면 기술적으로는 합법적이지만 보고할 가치가 있는 상당히 긴 특수 상황 목록을 만들 수 있습니다.

  • 파일 시스템 성능

디스크 액세스는 메모리 액세스보다 훨씬 느립니다. 32비트 메모리 워드를 읽는 데는 10나노초가 걸릴 수 있고, 하드 디스크에서 읽는 데는 100MB/초(32비트 워드당 읽기 속도의 4배)가 걸릴 수 있지만 여기에 트랙을 찾은 다음 필요한 섹터가 읽기 헤드 아래에 도착할 때까지 기다려야 합니다. 한 단어만 필요한 경우 메모리 액세스는 디스크 액세스보다 약 백만 배 빠릅니다. 액세스 시간의 차이로 인해 많은 파일 시스템은 성능 향상을 위해 다양한 최적화를 통해 설계되었습니다. 아래에서는 세 가지 유형을 소개합니다.

파일 시스템 성능을 향상시키는 첫 번째 방법은 캐싱입니다.

디스크 액세스를 줄이는 데 사용되는 가장 일반적인 기술은 블록 캐싱 또는 버퍼 캐싱입니다. 이 경우 캐시는 논리적으로 디스크에 속하지만 성능상의 이유로 메모리에 보관되는 블록 모음입니다.

캐시를 관리하는 데 다양한 알고리즘을 사용할 수 있지만 일반적인 알고리즘은 모든 읽기 요청을 확인하여 필요한 블록이 캐시에 있는지 확인하는 것입니다. 그렇다면 읽기 요청을 충족하기 위해 디스크 액세스가 필요하지 않습니다. 블록이 캐시에 없으면 먼저 캐시로 읽어온 다음 필요한 곳에 복사됩니다. 캐시는 동일한 블록에 대한 후속 요청을 충족할 수 있습니다.

캐시의 동작은 아래 그림과 같습니다. 캐시에는 많은(보통 수천 개의) 블록이 있으므로 특정 블록이 존재하는지 빠르게 확인하려면 어떤 방법이 필요합니다. 일반적인 접근 방식은 장치와 디스크 주소를 해시하고 해시 테이블에서 결과를 조회하는 것입니다. 동일한 해시 값을 가진 모든 블록은 연결 리스트로 연결되어 연쇄 충돌을 추적할 수 있습니다.

버퍼 캐시 데이터 구조.

IO 버퍼링 방식(입력).

블록을 전체 캐시에 로드해야 하는 경우 일부 블록을 제거해야 합니다(추가된 후 수정된 경우 디스크에 다시 작성해야 함). 이 상황은 페이징과 매우 유사하며 FIFO, Second Chance, LRU 등 일반적인 페이지 교체 알고리즘이 모두 적용됩니다. 페이징과 캐싱의 한 가지 좋은 차이점은 캐시 참조가 상대적으로 적기 때문에 연결된 목록을 사용하여 모든 블록을 정확한 LRU 순서로 유지할 수 있다는 것입니다.

위 이미지에서는 해시 테이블에서 시작하는 충돌 체인 외에도 모든 블록을 사용 순서대로 순회하는 양방향 목록도 있으며, 가장 최근에 사용된 블록이 목록 앞에 있고 가장 최근에 사용된 블록이 맨 끝에 있음을 알 수 있습니다. 블록이 참조되면 양방향 목록의 해당 위치에서 제거되어 끝에 배치될 수 있습니다. 이러한 방식으로 정확한 LRU 순서가 유지될 수 있습니다.

불행히도 문제가 있습니다. 이제 정확한 LRU를 달성할 가능성이 있으므로 LRU는 권장되지 않는 것으로 나타났습니다. 이 문제는 충돌 및 파일 시스템 일관성과 관련이 있습니다. 중요한 블록(예: inode 블록)을 캐시로 읽고 수정했지만 디스크에 다시 쓰지 않은 경우 충돌로 인해 파일 시스템이 일관되지 않은 상태가 됩니다. i-node 블록을 LRU 체인의 끝에 배치하면 프런트 엔드에 도달하여 디스크에 다시 기록되는 데 오랜 시간이 걸릴 수 있습니다.

또한 일부 블록(예: i-노드 블록)은 짧은 간격 내에 두 번 참조되는 경우가 거의 없습니다. 이러한 고려 사항으로 인해 다음 두 가지 요소를 고려하여 LRU 체계가 수정되었습니다.

  1. 블록이 곧 다시 필요하게 될까요?

  2. 파일 시스템 일관성에 블록이 중요한가요?

두 문제 모두에서 블록은 i-노드 블록, 간접 블록, 디렉토리 블록, 완전한 데이터 블록, 부분적으로 완전한 데이터 블록과 같은 범주로 나눌 수 있습니다. 머지않아 더 이상 필요하지 않은 블록은 LRU 목록의 뒷부분이 아닌 앞쪽에 배치되므로 해당 블록의 버퍼가 빠르게 재사용됩니다. 부분적으로 가득 찬 블록이 기록되는 등 곧 다시 필요할 수 있는 블록은 목록의 끝에 있으므로 오랫동안 유지됩니다.

두 번째 질문은 첫 번째 질문과 별개입니다. 블록이 파일 시스템 일관성(기본적으로 데이터 블록을 제외한 모든 것)에 중요하고 수정된 경우 LRU 목록의 어느 끝에 배치되어 있는지에 관계없이 즉시 디스크에 기록되어야 합니다. 중요한 블록을 빠르게 작성함으로써 충돌로 인해 파일 시스템이 파괴될 가능성을 크게 줄입니다.

파일 시스템 무결성을 유지하기 위해 이 방법을 사용하더라도 데이터 블록을 쓰기 전에 너무 오랫동안 캐시에 보관하고 싶지는 않을 것입니다. 책을 쓰기 위해 개인용 컴퓨터를 사용하는 사람의 곤경을 생각해 보십시오. 작성자가 주기적으로 편집자에게 편집 중인 파일을 디스크에 쓰라고 지시하더라도 모든 것이 여전히 캐시에 남아 있고 디스크에는 아무 것도 남지 않을 가능성이 높습니다. 시스템이 충돌하더라도 파일 시스템 구조는 손상되지 않지만 하루 전체 작업이 손실됩니다.

시스템은 이를 두 가지 방법으로 처리합니다. UNIX 접근 방식은 수정된 모든 블록을 즉시 디스크에 강제로 적용하는 동기화 시스템 호출을 사용하는 것입니다. 시스템이 부팅되면 프로그램(업데이트라고도 함)이 백그라운드에서 시작되어 무한 루프에서 동기식 호출을 수행하고 호출 사이에 30초 동안 휴면 상태를 유지합니다. 따라서 충돌로 인해 작업 시간이 손실된 시간은 30초도 채 되지 않습니다.

이제 Windows에는 Flush File Buffers라는 시스템 호출에 해당하는 동기 기능이 있지만 과거에는 그렇지 않았습니다. 대신 UNIX 접근 방식보다 어떤 면에서는 더 나은(어떤 면에서는 더 나쁜) 다른 전략을 가지고 있습니다. 이것이 하는 일은 수정된 각 블록이 캐시에 기록되는 즉시 디스크에 기록하는 것입니다. 모든 수정된 블록을 디스크에 즉시 다시 쓰는 캐시를 연속 기입 캐시라고 하며 비쓰기 캐시보다 더 많은 디스크 I/O가 필요합니다.

두 방법의 차이점은 프로그램이 한 번에 하나의 1KB 블록을 쓸 때 볼 수 있습니다. UNIX는 캐시의 모든 문자를 수집하여 30초마다 또는 캐시에서 블록이 제거될 때마다 이를 기록합니다. 연속 기입 캐싱의 경우 기록된 각 문자에 대해 하나의 디스크 액세스가 있습니다. 물론 대부분의 프로그램은 내부적으로 버퍼링되므로 일반적으로 문자를 쓰지 않고 쓰기 시스템 호출마다 한 줄 이상의 단위를 씁니다.

캐싱 전략의 이러한 차이로 인해 동기화하지 않고 UNIX 시스템에서 디스크를 제거하면 거의 항상 데이터가 손실되고 파일 시스템이 손상되는 경우가 많습니다. Write-through 캐싱을 사용하는 데에는 문제가 없습니다. 이러한 다양한 전략이 선택된 이유는 UNIX가 모든 디스크가 이동식이 아닌 하드 디스크인 환경에서 개발되었으며 최초의 Windows 파일 시스템이 플로피 디스크 세계에서 시작된 MS-DOS에서 상속되었기 때문입니다. 하드 드라이브가 표준이 되면서 효율성은 더 높지만 안정성은 더 떨어지는 UNIX 접근 방식이 표준이 되었으며 이제 Windows에서도 하드 드라이브에 사용됩니다. 그러나 앞서 언급한 것처럼 NTFS는 안정성을 높이기 위해 추가 조치(예: 로깅)를 수행합니다.

일부 운영 체제는 버퍼 캐시를 페이지 캐시와 통합합니다. 메모리 매핑 파일이 지원될 때 특히 매력적입니다. 파일이 메모리에 매핑된 경우 해당 페이지 중 일부는 요청 시 페이징되므로 메모리에 있을 수 있으며 이러한 페이지는 버퍼 캐시의 파일 블록과 사실상 구별되지 않습니다. 이 경우 파일 블록과 페이지 모두에 캐시를 사용하여 동일한 방식으로 처리할 수 있습니다.

파일 시스템 성능을 향상시키는 두 번째 방법은 Block Read Ahead입니다.

인식된 파일 시스템 성능을 향상시키는 두 번째 기술은 적중률을 높이기 위해 블록이 필요하기 전에 캐시에 블록을 넣는 것입니다. 특히 많은 파일을 순차적으로 읽습니다. 파일 시스템이 파일에 블록 k를 생성하라는 요청을 받으면 그렇게 하지만, 완료되면 몰래 캐시를 검사하여 블록 k+1이 이미 존재하는지 확인합니다. 그렇지 않은 경우 필요할 때 캐시에 도착할 것으로 기대하면서 블록 k+1 읽기를 예약합니다. 적어도 그것은 진행 중일 것입니다.

물론 이 미리 읽기 전략은 실제로 순차적으로 읽는 파일에만 적용됩니다. 파일이 무작위로 액세스되는 경우 미리 읽기는 도움이 되지 않습니다. 디스크 대역폭 읽기를 쓸모없는 블록으로 묶고 잠재적으로 유용한 블록을 캐시에서 제거한다는 사실(그리고 해당 블록이 더러워지면 잠재적으로 디스크에 다시 쓰기 위해 더 많은 디스크 대역폭을 묶을 수 있음)이 해롭습니다. 미리 읽기가 가치가 있는지 확인하기 위해 파일 시스템은 열려 있는 각 파일의 액세스 패턴을 추적할 수 있습니다. 예를 들어, 각 파일과 연관된 비트는 파일이 "순차 액세스 모드"인지 "임의 액세스 모드"인지를 추적합니다. 처음에는 파일에 의심의 여지가 주어지며 순차 액세스 모드로 전환됩니다. 그러나 탐색이 완료될 때마다 비트가 지워집니다. 순차 읽기가 다시 시작되면 비트가 다시 설정됩니다. 이런 방식으로 파일 시스템은 미리 읽어야 하는지 여부를 합리적으로 추측할 수 있습니다. 가끔씩 문제가 발생한다면 이는 재앙이 아니라 약간의 디스크 대역폭 낭비일 뿐입니다.

파일 시스템 성능을 향상시키는 세 번째 방법은 디스크 암 동작을 줄이는 것입니다(디스크-암 동작 감소).

캐싱과 미리 읽기가 파일 시스템 성능을 향상시키는 유일한 방법은 아닙니다. 또 다른 중요한 기술은 순차적으로 접근할 가능성이 있는 블록을 동일한 실린더에 배치하여 디스크 암의 움직임을 줄이는 것입니다. 출력 파일에 쓸 때 파일 시스템은 요청 시 한 번에 하나의 블록을 할당해야 합니다. 빈 블록이 비트맵에 기록되고 전체 비트맵이 메인 메모리에 있는 경우 이전 블록과 최대한 가까운 빈 블록을 선택하는 것은 충분히 쉽습니다. 사용 가능한 목록(일부가 디스크에 있음)을 사용하면 블록을 서로 밀접하게 할당하기가 어렵습니다.

그러나 사용 가능한 목록이 있어도 일부 블록 클러스터링이 수행될 수 있습니다. 비결은 블록이 아닌 연속 블록 그룹별로 디스크 스토리지를 추적하는 것입니다. 모든 섹터가 512바이트로 구성된 경우 시스템은 1KB 블록(2섹터)을 사용할 수 있지만 디스크 저장소는 2블록(4섹터) 단위로 할당됩니다. 2KB 디스크 블록을 갖는 것과 비교하면 캐시는 여전히 1KB 블록을 사용하므로 디스크 전송은 여전히 ​​1KB이지만 유휴 시스템에서 파일을 순차적으로 읽으면 검색 횟수가 두 배로 줄어들어 성능이 크게 향상됩니다. 동일한 주제의 변형은 회전 위치 지정을 고려하는 것입니다. 블록을 할당할 때 시스템은 동일한 실린더 내의 파일에 연속 블록을 배치하려고 시도합니다.

i-node 또는 이와 유사한 시스템을 사용하는 시스템의 또 다른 성능 병목 현상은 짧은 파일이라도 읽으려면 두 개의 디스크 액세스가 필요하다는 것입니다. 하나는 i-node용이고 다른 하나는 블록용입니다. 일반적인 i-노드 레이아웃은 아래 그림(a)에 나와 있습니다. 여기서 모든 i-노드는 디스크 시작 부분에 가깝기 때문에 inode와 해당 블록 사이의 평균 거리는 실린더 수의 절반이므로 긴 검색이 필요합니다.

간단한 성능 향상은 i-노드를 디스크의 시작 부분이 아닌 중앙에 배치하여 i-노드와 첫 번째 블록 사이의 평균 검색을 2배로 줄이는 것입니다. 아래 그림 (b)에 표시된 또 다른 아이디어는 디스크를 각각 자체 i-노드, 블록 및 여유 목록이 있는 실린더 그룹으로 나누는 것입니다. 새 파일을 생성할 때 모든 i-노드를 선택할 수 있지만 i-노드와 동일한 실린더 그룹에서 블록을 찾으려고 시도합니다. 사용 가능한 실린더 그룹이 없으면 가까운 실린더 그룹의 실린더 그룹이 사용됩니다.

(a) 디스크의 시작 부분에 위치한 I 노드입니다. (b) 디스크는 실린더 그룹으로 나누어지며, 각 그룹에는 자체 블록과 i-노드가 있습니다.

물론 디스크에 디스크 암 동작과 회전 시간이 있는 경우에만 관련이 있습니다. 점점 더 많은 컴퓨터에 움직이는 부품이 없는 SSD(Solid State Disk)가 장착되어 있습니다. 플래시 메모리 카드와 동일한 기술을 기반으로 구축된 이러한 디스크의 경우 랜덤 액세스가 순차 액세스만큼 빠르며 기존 디스크의 많은 문제가 사라집니다. 불행히도 새로운 문제가 발생합니다. 예를 들어 SSD에는 읽기, 쓰기, 삭제 시 특별한 속성이 있습니다. 특히, 각 블록은 제한된 횟수만큼만 쓸 수 있으므로 디스크 전체에 마모가 고르게 퍼지도록 세심한 주의를 기울입니다.

18.11.3.9 디스크 조각 모음

운영 체제가 처음 설치되면 필요한 프로그램과 파일이 디스크의 시작 부분부터 순차적으로 설치되며, 각 프로그램과 파일은 이전 프로그램과 파일 바로 뒤에 설치됩니다. 사용 가능한 모든 디스크 공간은 설치 파일 다음에 이어지는 단일 연속 단위에 있습니다. 그러나 시간이 지남에 따라 파일이 생성되고 삭제되며, 디스크가 파일과 구멍으로 가득 차서 심하게 조각나는 경우가 많습니다. 따라서 새 파일이 생성되면 해당 파일에 사용된 블록이 디스크 전체에 분산되어 성능이 저하될 수 있습니다.

파일을 연속적으로 이동하고 디스크의 하나 이상의 큰 연속 영역에 여유 공간 전체(또는 적어도 대부분)를 배치하여 성능을 복원할 수 있습니다. Windows에는 바로 이러한 작업을 수행하는 조각 모음 프로그램이 있습니다. Windows 사용자는 SSD를 제외하고 정기적으로 실행해야 합니다.

조각 모음은 파티션 끝의 인접 영역에 여유 공간이 많은 파일 시스템에서 더 잘 작동합니다. 이 공간을 통해 조각 모음 프로그램은 파티션 시작 부분에 있는 조각난 파일을 선택하고 모든 블록을 여유 공간에 복사할 수 있습니다. 이렇게 하면 파티션 시작 부분 근처에 인접한 공간 블록이 확보되어 원본 파일이나 다른 파일을 연속적으로 배치할 수 있습니다. 그런 다음 다음 디스크 공간 등을 사용하여 프로세스를 반복할 수 있습니다.

페이징 파일, 최대 절전 모드 파일 및 로깅을 포함한 일부 파일은 이동할 수 없습니다. 이동하는 데 필요한 관리 노력으로 인해 필요 이상으로 많은 문제가 발생하기 때문입니다. 일부 시스템에서는 이러한 영역이 고정된 크기의 연속 영역이므로 조각 모음을 수행할 필요가 없습니다. 이동성 부족으로 인한 한 가지 문제는 파티션의 끝에 있고 사용자가 파티션 크기를 줄이고 싶어한다는 것입니다. 이 문제를 해결하는 유일한 방법은 완전히 삭제하고 파티션 크기를 조정한 다음 나중에 다시 만드는 것입니다.

디스크 블록이 선택되는 방식으로 인해 Linux 파일 시스템(특히 ext2 및 ext3)은 일반적으로 Windows 시스템보다 조각 모음이 덜 수행되므로 수동 조각 모음이 거의 필요하지 않습니다. 또한 SSD는 실제로 조각화의 영향을 전혀 받지 않습니다. 실제로 SSD 조각 모음은 역효과를 낳을 수 있습니다. 성능이 향상되지 않을 뿐만 아니라 SSD도 마모되므로 조각 모음을 하면 수명이 단축될 뿐입니다.

18.11.3.10 파일 시스템 사례

일반적인 파일 시스템 예로는 MS-DOS 파일 시스템, UNIX V7 파일 시스템 및 CD-ROM 파일 시스템이 있습니다.

초기 버전의 UNIX에도 MULTICS에서 파생되었기 때문에 상당히 복잡한 다중 사용자 파일 시스템이 있었습니다. 다음으로 UNIX를 유명하게 만든 PDP-11 파일 시스템인 V7 파일 시스템에 대해 논의하겠습니다.

파일 시스템은 루트에서 시작하여 링크가 추가되어 방향성 비순환 그래프(DAG)를 형성하는 트리 형태를 취합니다. 파일 이름은 최대 14자까지 가능하며 /(경로의 구성 요소 간 구분 기호이므로) 및 NUL(14자 미만의 이름을 채우는 데 사용됨)(숫자 값 0)을 제외한 모든 ASCII 문자를 포함할 수 있습니다.

UNIX 디렉토리 항목에는 디렉토리의 각 파일에 대해 하나의 항목이 포함됩니다. UNIX는 i-노드 체계를 사용하므로 각 항목은 매우 간단합니다. 디렉토리 항목에는 아래 그림과 같이 파일 이름(14바이트)과 파일의 i-node 수(2바이트)라는 두 개의 필드만 포함됩니다. 이러한 매개변수는 파일 시스템당 파일 수를 64K로 제한합니다.

i-node와 유사하게 UNIX i-node에는 몇 가지 속성이 포함되어 있습니다. 이러한 속성에는 파일 크기, 삼중성(생성, 마지막 액세스 및 마지막 수정), 소유자, 그룹, 보호 정보 및 i-노드를 가리키는 디렉터리 항목 수가 포함됩니다. 후자의 필드는 연결로 인해 필요합니다. i-노드에 대한 새 링크가 설정될 때마다 i-노드의 수가 증가합니다. 링크가 제거되면 개수가 감소합니다. 0에 도달하면 i-노드가 회수되고 디스크 블록이 사용 가능 목록에 다시 배치됩니다.

매우 큰 파일을 처리하기 위해 디스크 블록을 추적할 수 있습니다. 처음 10개의 디스크 주소는 i-노드 자체에 저장되므로 작은 파일의 경우 필요한 모든 정보는 파일이 열릴 때 디스크에서 주 메모리로 가져오는 i-노드에 있습니다. 더 큰 파일의 경우 inode의 주소 중 하나는 단일 간접 블록이라는 디스크 블록의 주소입니다. 이 블록에는 추가 디스크 주소가 포함되어 있습니다. 그래도 충분하지 않은 경우 i-노드의 다른 주소(이중 간접 블록이라고 함)에는 단일 간접 블록 목록이 포함된 블록의 주소가 포함됩니다. 각 간접 블록은 수백 개의 데이터 블록을 가리킵니다. 충분하지 않다면 삼중 간접 블록을 사용할 수도 있습니다. 전체 이미지는 아래와 같습니다.

파일을 열 때 파일 시스템은 제공된 파일 이름을 사용하고 해당 디스크 블록을 찾아야 합니다. 경로 이름 /usr/ast/mbox를 찾는 방법을 고려해 보겠습니다. 예를 들어 UNIX를 사용하지만 알고리즘은 기본적으로 모든 계층적 디렉터리 시스템에서 동일합니다. 먼저, 파일 시스템은 루트 디렉터리를 찾습니다. UNIX에서는 해당 i-노드가 디스크의 고정된 위치에 있습니다. 이 i-노드에서 디스크의 어느 위치에나 있을 수 있는 루트 디렉터리(블록 1일 수 있음)를 찾습니다.

그런 다음 루트 디렉터리를 읽고 루트 디렉터리에서 경로의 첫 번째 구성 요소인 usr을 찾아 /usr 파일의 i-노드 번호를 찾습니다. 각 노드가 디스크에서 고정된 위치를 갖고 있기 때문에 번호를 기준으로 i-노드를 찾는 것은 간단합니다. 이 i-노드에서 시스템은 /usr 디렉토리를 찾고 거기에서 다음 구성 요소를 찾습니다. ast에 대한 항목을 찾으면 /usr/ast 디렉토리에 대한 i-노드가 있습니다. 이 i-노드에서 디렉토리 자체를 찾고 mbox를 찾을 수 있습니다. 그러면 이 파일의 i-노드가 메모리로 읽혀지고 파일이 닫힐 때까지 메모리에 보관됩니다. 검색 과정은 아래 그림과 같습니다.

상대 경로 이름은 절대 경로 이름과 동일한 방식으로 조회되며 루트 디렉터리 대신 작업 디렉터리에서만 시작됩니다. 모든 디렉토리에는 . 그리고 ..는 디렉토리가 생성될 때 거기에 배치됩니다. 기입. 현재 디렉토리의 i-노드 번호를 가지고 있습니다. 항목은 상위 디렉토리의 i-노드 번호를 가지고 있습니다. 따라서 ../dick/prog를 찾는 과정이 필요합니다. c 작업 디렉터리를 살펴보고... 상위 디렉터리의 i-노드 번호를 찾은 다음 해당 디렉터리에서 Dick을 검색하세요. 이러한 이름을 처리하는 데 특별한 메커니즘은 필요하지 않습니다. 디렉토리 시스템에 관한 한 이 이름은 다른 이름과 마찬가지로 일반 ASCII 문자열일 뿐이며 유일한 비결은 루트 디렉토리의 ..가 자신을 가리키는 것입니다.

다음 그림은 Linux 가상 파일 시스템 컨텍스트입니다.

다음 그림은 Linux 가상 파일 시스템의 개념입니다.

18.11.4 I/O

운영체제는 프로세스, 주소 공간, 파일 등 추상적인 개념을 제공하는 것 외에도 컴퓨터의 모든 I/O(입/출력) 장치를 제어합니다. 장치에 명령을 실행하고, 인터럽트를 포착하고, 오류를 처리해야 합니다. 또한 장치와 시스템의 나머지 부분 간에 사용하기 쉬운 인터페이스를 제공해야 합니다. 가능하다면 모든 장치의 인터페이스는 동일해야 합니다(장치 독립성). I/O 코드는 전체 운영 체제에서 큰 부분을 차지합니다.

18.11.4.1 I/O 하드웨어 원리

사람들은 I/O 하드웨어를 다양한 방식으로 생각합니다. 전기 엔지니어는 하드웨어를 구성하는 칩, 전선, 전원 공급 장치, 모터 및 기타 모든 물리적 구성 요소의 측면에서 이를 살펴보고, 프로그래머는 소프트웨어에 제공되는 인터페이스(하드웨어가 허용하는 명령, 수행하는 기능 및 보고할 수 있는 오류)를 살펴봅니다. 우리는 I/O 장치를 설계, 구축 또는 유지 관리하는 것이 아니라 프로그래밍하는 데 관심을 가져야 합니다. 따라서 우리의 관심은 하드웨어가 내부적으로 작동하는 방식이 아니라 하드웨어가 프로그래밍되는 방식에 있습니다. 그러나 많은 I/O 장치의 프로그래밍은 내부 작업과 밀접하게 연결되어 있는 경우가 많습니다. 다음에서는 프로그래밍과 관련된 I/O 하드웨어에 대한 일반적인 배경 지식을 제공합니다.

I/O 장치는 크게 블록 장치문자 장치의 두 가지 범주로 나눌 수 있습니다. 블록 장치는 고정된 크기의 블록에 정보를 저장하는 장치입니다. 각 블록에는 고유한 주소가 있습니다. 일반적인 블록 크기는 512바이트에서 65536바이트까지 다양합니다. 모든 전송은 하나 이상의 완전한(연속) 블록 단위로 이루어집니다. 블록 디바이스의 기본 특징은 각 블록을 다른 모든 블록과 독립적으로 읽거나 쓸 수 있다는 것입니다. 하드 드라이브, Blu-ray 디스크 및 USB 디스크는 일반적인 블록 장치입니다.

자세히 살펴보면 블록 주소 지정이 가능한 장치와 그렇지 않은 장치 간의 경계가 잘 정의되어 있지 않습니다. 팔이 현재 어디에 있든 항상 다른 실린더를 찾은 다음 원하는 블록이 헤드 아래에서 회전할 때까지 기다릴 수 있기 때문에 디스크가 블록 주소 지정 장치라는 데 모두가 동의합니다. 이제, 때때로 디스크 백업용으로 사용되는 오래된 테이프 머신을 생각해 보십시오(테이프가 저렴하기 때문에). 테이프에는 일련의 블록이 포함되어 있으며 테이프 드라이브가 블록 N을 읽으라는 명령을 받으면 블록 N에 도달할 때까지 항상 되감고 앞으로 이동할 수 있습니다. 이 작업은 시간이 더 오래 걸린다는 점을 제외하면 디스크 검색과 유사합니다. 또한 테이프 중간에 블록을 다시 쓰는 것이 가능할 수도 있고 불가능할 수도 있습니다. 테이프를 임의 액세스 블록 장치로 사용하여 이를 어느 정도 확장할 수 있지만 일반적으로 그런 방식으로 사용되지는 않습니다.

또 다른 유형의 I/O 장치는 문자 장치입니다. 문자 장치는 블록 구조에 관계없이 문자 스트림을 보내거나 받습니다. 주소를 지정할 수 없으며 탐색 작업을 수행하지 않습니다. 프린터, 네트워크 인터페이스, 마우스(포인팅에 사용됨), 마우스(심리 실험실 실험에 사용됨) 및 대부분의 기타 비디스크 장치는 문자 장치로 간주될 수 있습니다.

이 분류 체계는 완벽하지 않으며 일부 장치는 적합하지 않습니다. 예를 들어, 시계는 블록 주소 지정이 불가능하며 문자 스트림을 생성하거나 허용하지도 않습니다. 시계가 하는 일은 잘 정의된 간격으로 인터럽트를 발생시키는 것뿐입니다. 메모리 매핑 화면도 이 모델에 적합하지 않으며 터치스크린에도 적합하지 않습니다. 그럼에도 불구하고 블록 및 문자 장치에 대한 모델은 I/O 장치를 독립적으로 처리하는 일부 운영 체제 소프트웨어를 만드는 기초로 사용할 수 있을 만큼 충분히 일반적입니다. 예를 들어, 파일 시스템은 추상 블록 장치만 처리하고 장치 종속 부분은 하위 수준 소프트웨어에 맡깁니다.

I/O 장치의 넓은 속도 범위는 소프트웨어가 성능을 몇 배나 초과하는 데이터 속도로 수행해야 한다는 상당한 압력을 가합니다. 아래 표는 일부 일반적인 장치의 데이터 속도를 보여줍니다. 이러한 장치의 대부분은 시간이 지남에 따라 더 빨라질 것입니다.

장비 데이터 속도(단위: 초당)

키보드 10B

쥐 100B

56K 모뎀 7.0KB

300dpi 스캐너 1.0MB

디지털 비디오 카메라 3.5MB

4x 블루레이 디스크 18.0MB

802.11n 무선 37.5MB

USB 2.0 60.0MB

파이어와이어 800 100MB

기가비트 이더넷 125MB

SATA 3 디스크 드라이브 600MB

USB 3.0 625MB

SCSI Ultra 5 버스 640MB

단일 레인 PCIe 3.0 버스 985MB

썬더볼트 2 버스 2.5GB

SONET OC-768 네트워크 5.0GB

다음은 몇 가지 일반적인 IO 조직 모델입니다.

장치 컨트롤러

I/O 장치는 일반적으로 기계 부품과 전자 부품으로 구성됩니다. 두 부분을 분리하여 보다 모듈화되고 다양한 디자인을 제공할 수 있습니다. 전자 부품은 장치 컨트롤러 또는 어댑터라고 하며, 개인용 컴퓨터에서는 일반적으로 마더보드의 칩 형태나 (PCIe) 확장 슬롯에 연결되는 인쇄 회로 카드 형태를 취합니다. 기계 구성 요소는 장치 자체입니다.

일반적으로 컨트롤러 카드에는 장치 자체에 연결되는 케이블에 연결되는 커넥터가 있습니다. 많은 컨트롤러가 2개, 4개, 심지어 8개의 동일한 장치를 처리할 수 있습니다. 컨트롤러와 장치 간의 인터페이스가 공식 ANSI, IEEE, ISO 표준 또는 사실상의 표준일 수 있는 표준 인터페이스인 경우 회사는 인터페이스에 맞는 컨트롤러 또는 장치를 제조할 수 있습니다. 예를 들어, 많은 회사에서는 SATA, SCSI, USB, Thunderbolt 또는 FireWire(IEEE 1394) 인터페이스에 맞는 디스크 드라이브를 생산합니다.

컨트롤러와 장치 사이의 인터페이스는 일반적으로 매우 낮은 수준의 인터페이스입니다. 예를 들어, 디스크는 2,000,000개 섹터, 트랙당 512바이트로 포맷될 수 있습니다. 그러나 실제로 드라이브에서 나오는 것은 프리앰블부터 시작하여 해당 섹터의 4096비트, 마지막으로 체크섬인 ECC(Error Correcting Code)인 직렬 비트 스트림입니다. 프리앰블은 디스크가 포맷될 때 작성되며 실린더 및 섹터 번호, 섹터 크기, 유사한 데이터 및 동기화 정보를 포함합니다.

컨트롤러의 임무는 직렬 비트 스트림을 바이트 블록으로 변환하고 필요한 오류 수정을 수행하는 것입니다. 바이트 블록은 일반적으로 먼저 컨트롤러 내의 버퍼에서 비트 단위로 조립됩니다. 체크섬이 확인되고 블록에 오류가 없다고 선언된 후에는 주 메모리에 복사할 수 있습니다.

LCD 디스플레이 컨트롤러는 유사한 하위 수준 비트 직렬 장치로 작동할 수도 있습니다. 이는 표시할 문자가 포함된 바이트를 메모리에서 읽고 해당 픽셀의 백라이트 편광을 수정하여 화면에 쓸 수 있도록 신호를 생성합니다. 디스플레이 컨트롤러가 없으면 운영 체제 프로그래머는 모든 픽셀에 대한 전기장을 명시적으로 프로그래밍해야 합니다. 컨트롤러를 사용하면 운영 체제는 라인당 문자 수 또는 픽셀 수, 화면당 라인 수와 같은 일부 매개변수를 사용하여 컨트롤러를 초기화하고 컨트롤러가 실제로 전기장을 구동하는 일을 처리하도록 합니다.

매우 짧은 시간에 LCD 화면이 기존 CRT(음극선관) 모니터를 완전히 대체했습니다. CRT 모니터는 형광 스크린에 전자 빔을 발사하고 자기장을 사용하여 시스템이 빔을 구부려 화면에 픽셀을 그릴 수 있습니다. LCD 화면에 비해 CRT 모니터는 부피가 크고 전력을 많이 소모하며 깨지기 쉽습니다. 더욱이 오늘날의 (망막) LCD 화면의 해상도는 너무 좋아서 인간의 눈으로는 개별 픽셀을 확인할 수 없습니다. 과거 노트북에 세로가 20cm가 넘고 무게가 약 12kg에 달하는 작은 CRT 화면이 탑재됐다는 것은 오늘날 상상하기 어렵다.

메모리 매핑된 I/O

각 컨트롤러에는 CPU와 통신하는 데 사용되는 여러 레지스터가 있습니다. 이러한 레지스터에 기록함으로써 운영 체제는 장치에 데이터 전송, 데이터 수신, 자체 켜기 또는 끄기, 특정 작업 수행을 명령할 수 있습니다. 이러한 레지스터를 읽으면 운영 체제는 장치의 상태, 새 명령을 받아들일 준비가 되었는지 등을 알 수 있습니다.

제어 레지스터 외에도 많은 장치에는 운영 체제가 읽고 쓸 수 있는 데이터 버퍼가 있습니다. 예를 들어, 컴퓨터가 화면에 픽셀을 표시하는 일반적인 방법 중 하나는 비디오 RAM을 사용하는 것입니다. 이는 기본적으로 프로그램이나 운영 체제가 쓸 수 있는 데이터 버퍼일 뿐입니다.

따라서 CPU가 제어 레지스터 및 장치 데이터 버퍼와 어떻게 통신하는지에 대한 의문이 제기됩니다. 두 가지 옵션이 있습니다. 첫 번째 방법에서는 각 제어 레지스터에 I/O 포트 번호, 즉 8비트 또는 16비트 정수가 할당됩니다. 모든 I/O 포트의 집합은 I/O 포트 공간을 구성하며 일반 사용자 프로그램이 접근할 수 없도록 보호됩니다(운영 체제만 접근 가능). 다음과 같은 특수 I/O 명령어를 사용합니다.

cpp
IN REG, PORT,

CPU는 제어 레지스터 PORT를 읽고 결과를 CPU 레지스터 REG에 저장할 수 있습니다. 마찬가지로 다음을 사용합니다.

cpp
OUT PORT, REG

CPU는 REG의 내용을 제어 레지스터에 쓸 수 있습니다. IBM 360 및 모든 후속 제품과 같은 거의 모든 메인프레임을 포함한 대부분의 초기 컴퓨터는 이러한 방식으로 작동했습니다. 이 솔루션에서는 아래 그림 (a)와 같이 메모리와 I/O의 주소 공간이 다릅니다. IN R0,4 및 MOV R0,4 명령은 이 설계에서 완전히 다릅니다. 전자는 I/O 포트 4의 내용을 읽어 R0에 배치하고, 후자는 메모리 워드 4의 내용을 읽어 R0에 배치합니다. 이 예에서 4는 서로 다르고 관련되지 않은 주소 공간을 나타냅니다.

(a) I/O와 메모리 공간을 분리합니다. (b) 메모리 매핑된 I/O. (c) 혼합.

PDP-11에서 도입한 두 번째 방법은 위의 (b)와 같이 모든 제어 레지스터를 메모리 공간에 매핑하는 것입니다. 각 제어 레지스터에는 고유한 메모리 주소가 할당되지만 메모리는 할당되지 않습니다. 이 시스템을 메모리 매핑 I/O라고 합니다. 대부분의 시스템에서 할당된 주소는 주소 공간의 상단 또는 그 근처에 있습니다. 위의 그림 (c)는 메모리 매핑된 I/O 데이터 버퍼와 제어 레지스터를 위한 별도의 I/O 포트를 갖춘 하이브리드 방식을 보여줍니다. x86은 이 아키텍처를 사용하며, I/O 포트 0~64K를 제외하고 주소 640K~1M-1, IBM PC 호환 장치의 장치 데이터 버퍼용으로 예약된 1-1을 사용합니다.

이 계획은 실제로 어떻게 작동합니까? 모든 경우에 CPU가 메모리나 I/O 포트에서 단어를 읽으려고 할 때 원하는 주소를 버스의 주소 라인에 배치한 다음 버스의 제어 라인에 읽기 신호를 표시하고 두 번째 신호 라인을 사용하여 I/O 공간 또는 메모리 공간이 필요한지 여부를 결정합니다. 메모리 공간인 경우 메모리는 요청에 응답합니다. I/O 공간이면 I/O 장치가 요청에 응답합니다. 메모리 공간만 있는 경우(위의 (b)) 각 메모리 모듈과 각 I/O 장치는 주소 라인을 해당 주소 범위와 비교합니다. 주소가 해당 범위 내에 있으면 요청에 응답합니다. 주소는 메모리 및 I/O 장치에 할당되지 않으므로 모호함이나 충돌이 없습니다.

이 두 가지 컨트롤러 주소 지정 체계에는 서로 다른 장점과 단점이 있습니다. 먼저 메모리 매핑 I/O의 장점을 설명합니다.

  • 첫째, 장치 제어 레지스터를 읽고 쓰기 위해 특수 I/O 명령어가 필요한 경우 IN 또는 OUT 명령어는 C 또는 C++에서 실행될 수 없고 이러한 프로시저를 호출하면 I/O 제어 오버헤드가 증가하므로 이에 액세스하려면 어셈블리 코드를 사용해야 합니다. 대조적으로, 메모리 매핑된 I/O의 경우 장치 제어 레지스터는 다른 변수와 동일한 방식으로 C에서 주소를 지정할 수 있는 메모리의 단순한 변수입니다. 따라서 메모리 매핑된 I/O를 사용하면 I/O 장치 드라이버를 완전히 C로 작성할 수 있습니다. 메모리 매핑된 I/O가 없으면 일부 어셈블리 코드가 필요합니다.

  • 둘째, 메모리 매핑된 I/O의 경우 사용자 프로세스가 I/O를 수행하는 것을 방지하기 위해 특별한 보호 메커니즘이 필요하지 않습니다. 운영 체제가 해야 할 일은 제어 레지스터를 포함하는 주소 공간의 일부를 사용자의 가상 주소 공간에 배치하지 않는 것입니다. 더 좋은 점은 각 장치의 제어 레지스터가 주소 공간의 서로 다른 페이지에 있는 경우 운영 체제는 페이지 테이블에 필요한 페이지를 포함함으로써 사용자가 특정 장치를 제어할 수 있지만 다른 장치는 제어할 수 없도록 허용할 수 있다는 것입니다. 이러한 체계는 서로 다른 장치 드라이버를 서로 다른 주소 공간에 배치하여 커널 크기를 줄일 뿐만 아니라 한 드라이버가 다른 드라이버를 방해하는 것을 방지합니다.

  • 셋째, 메모리 매핑된 I/O를 사용하면 메모리를 참조할 수 있는 모든 명령어가 제어 레지스터도 참조할 수 있습니다. 예를 들어, 메모리 워드 0을 테스트하는 명령 TEST가 있는 경우 이는 장치가 유휴 상태이고 새 명령을 받아들일 수 있다는 신호로 제어 레지스터 0을 테스트하는 데 사용될 수도 있습니다. 어셈블리 언어 코드는 다음과 같습니다.

cpp
LOOP: TEST PORT 4  // check if por t 4 is 0
      BEQ READY    // if it is 0, go to ready
      BRANCH LOOP  // otherwise, continue testing
READY:

메모리 매핑된 I/O가 없는 경우 제어 레지스터를 먼저 CPU로 읽어온 다음 테스트해야 하며, 이를 위해서는 하나가 아닌 두 개의 명령이 필요합니다. 위 루프의 경우 유휴 장치 감지 응답 속도를 약간 줄이는 네 번째 명령을 추가해야 합니다.

컴퓨터 디자인에서는 거의 모든 것이 절충안을 포함하며, 이는 여기에서도 마찬가지입니다. 메모리 매핑된 I/O에도 단점이 있습니다.

  • 첫째, 오늘날 대부분의 컴퓨터에는 어떤 형태로든 메모리 워드 캐시가 있습니다. 장치 제어 레지스터를 캐싱하는 것은 재앙이 될 수 있습니다. 위에 제공된 캐시된 어셈블리 코드 루프를 고려하세요. PORT 4에 대한 첫 번째 참조로 인해 캐시됩니다. 후속 참조는 캐시에서 값을 가져오며 장치에 묻지도 않습니다. 그러면 장치가 최종적으로 준비되면 소프트웨어가 장치를 검색할 수 없게 됩니다. 대신, 그 순환은 영원히 계속될 것입니다.

메모리 매핑된 I/O에서 이러한 일이 발생하지 않도록 하려면 하드웨어가 예를 들어 페이지 단위로 캐싱을 선택적으로 비활성화할 수 있어야 합니다. 이 기능은 선택적 캐싱을 관리해야 하는 하드웨어 및 운영 체제에 복잡성을 더합니다.

  • 둘째, 주소 공간이 하나만 있는 경우 모든 메모리 모듈과 모든 I/O 장치는 응답할 메모리 참조를 확인하기 위해 모든 메모리 참조를 확인해야 합니다. 아래 그림 (a)와 같이 컴퓨터에 버스가 하나만 있는 경우 모든 사람이 모든 주소를 보도록 하는 것은 간단합니다.

그러나 현대 개인용 컴퓨터의 추세는 위의 (b)와 같이 전용 고속 메모리 버스를 갖는 것입니다. 버스는 느린 I/O 장치로 인해 속도가 저하되지 않고 메모리 성능을 최적화하도록 맞춤화되었습니다. x86 시스템에는 여러 버스(메모리, PCIe, SCSI 및 USB)가 있을 수 있습니다.

메모리 매핑된 시스템에서 별도의 메모리 버스를 사용할 때의 문제점은 I/O 장치가 메모리 버스를 통과할 때 메모리 주소를 볼 수 없기 때문에 이에 응답할 수 없다는 것입니다. 마찬가지로, 여러 버스가 있는 시스템에서 메모리 매핑된 I/O가 작동하도록 하려면 특별한 조치를 취해야 합니다. 한 가지 가능성은 모든 메모리 참조를 먼저 메모리에 보내는 것입니다. 메모리가 응답하지 않으면 CPU는 다른 버스를 시도합니다. 이 디자인은 작동할 수 있지만 추가적인 하드웨어 복잡성이 필요합니다.

두 번째 가능한 설계는 제시된 모든 주소를 잠재적으로 관심 있는 I/O 장치에 전달하는 청취 장치를 메모리 버스에 배치하는 것입니다. 여기서 문제는 I/O 장치가 메모리만큼 빠르게 요청을 처리하지 못할 수도 있다는 것입니다.

세 번째 가능한 설계는 메모리 컨트롤러의 주소를 필터링하는 것입니다. 이 경우 메모리 컨트롤러 칩에는 부팅 시 미리 로드되는 범위 레지스터가 포함되어 있습니다. 예를 들어 640K부터 1M−1까지를 비메모리 범위로 표시할 수 있습니다. 비메모리로 표시된 범위 중 하나에 속하는 주소는 메모리 대신 장치로 전달됩니다. 이 방식의 단점은 부팅 시 어떤 메모리 주소가 실제 메모리 주소가 아닌지 확인해야 한다는 것입니다. 따라서 모든 선택에는 찬반 논쟁이 있기 때문에 타협과 절충은 불가피합니다.

직접 메모리 액세스

CPU에 메모리 매핑 I/O가 있는지 여부에 관계없이 장치 컨트롤러와 데이터를 교환하려면 주소를 지정해야 합니다. CPU는 I/O 컨트롤러에 한 번에 한 바이트씩 데이터를 요청할 수 있지만 그렇게 하면 CPU 시간이 낭비되므로 일반적으로 DMA(직접 메모리 액세스)라는 다른 방식이 사용됩니다. 설명을 단순화하기 위해 아래 그림과 같이 CPU가 CPU, 메모리, I/O 장치를 연결하는 단일 시스템 버스를 통해 모든 장치와 메모리에 액세스한다고 가정하겠습니다. 우리는 현대 시스템의 실제 조직이 더 복잡하다는 것을 이미 알고 있지만 모든 원칙은 동일합니다. 운영 체제는 대부분의 시스템과 마찬가지로 하드웨어에 DMA 컨트롤러가 있는 경우에만 DMA를 사용할 수 있습니다. 때때로 이 컨트롤러는 디스크 컨트롤러 및 기타 컨트롤러에 통합되지만 이 설계에서는 각 장치마다 별도의 DMA 컨트롤러가 필요합니다. 보다 일반적으로 단일 DMA 컨트롤러(예: 마더보드)를 사용하여 일반적으로 동시에 여러 장치로의 전송을 조절할 수 있습니다.

DMA 컨트롤러는 아래 그림과 같이 CPU와 별개로 시스템 버스에 액세스할 수 있습니다. 여기에는 CPU에 쓰고 읽을 수 있는 여러 레지스터가 포함되어 있습니다. 이러한 레지스터에는 메모리 주소 레지스터, 바이트 카운트 레지스터, 사용할 I/O 포트, 전송 방향(I/O 장치에서 읽기 또는 쓰기), 전송 단위(시간당 바이트 또는 시간당 단어) 및 버스트에서 전송되는 바이트 수를 지정하는 하나 이상의 제어 레지스터가 포함됩니다.

DMA 작동 방식을 설명하기 위해 먼저 DMA 없이 디스크 읽기가 발생하는 방식을 살펴보겠습니다. 먼저, 디스크 컨트롤러는 전체 블록이 컨트롤러의 내부 버퍼에 있을 때까지 드라이브에서 블록(하나 이상의 섹터)을 비트 단위로 직렬로 읽습니다. 다음으로 체크섬을 계산하여 읽기 오류가 발생하지 않았는지 확인합니다. 그러면 컨트롤러가 인터럽트를 발생시킵니다. 운영 체제가 실행을 시작하면 루프를 실행하고 각 반복마다 컨트롤러 장치 레지스터에서 한 문자 또는 단어를 읽고 이를 주 메모리에 저장하여 컨트롤러의 버퍼에서 한 번에 한 바이트 또는 단어씩 디스크 블록을 읽을 수 있습니다.

DMA 전송 작업.

DMA를 사용하는 경우 프로세스가 다릅니다. 먼저, CPU는 무엇을 어디에 전송할지 알 수 있도록 레지스터를 설정하여 DMA 컨트롤러를 프로그래밍합니다(위 이미지의 1단계). 또한 디스크 컨트롤러에 명령을 내려 디스크의 데이터를 내부 버퍼로 읽고 체크섬을 확인하도록 지시합니다. DMA는 유효한 데이터가 디스크 컨트롤러의 버퍼에 있을 때 시작될 수 있습니다.

DMA 컨트롤러는 버스를 통해 디스크 컨트롤러에 읽기 요청을 보내 전송을 시작합니다(2단계). 이 읽기 요청은 다른 읽기 요청과 유사하며 디스크 컨트롤러는 이 요청이 CPU에서 오는지 DMA 컨트롤러에서 오는지 알지 못합니다. 일반적으로 쓰여질 메모리 주소는 버스의 주소 라인에 있으므로 디스크 컨트롤러가 내부 버퍼에서 다음 단어를 얻을 때 어디에 쓸지 알 수 있습니다. 메모리에 쓰는 것은 또 다른 표준 버스 사이클입니다(3단계). 쓰기가 완료되면 디스크 컨트롤러는 버스를 통해 DMA 컨트롤러에 승인 신호도 보냅니다(4단계). 그런 다음 DMA 컨트롤러는 사용할 메모리 주소를 늘리고 바이트 수를 줄입니다. 바이트 수가 여전히 0보다 크면 수가 0에 도달할 때까지 2~4단계를 반복합니다. 이 시점에서 DMA 컨트롤러는 CPU에 인터럽트를 걸어 이제 전송이 완료되었음을 알립니다. 운영 체제가 부팅될 때 디스크 블록이 이미 메모리에 있으므로 디스크 블록을 메모리에 복사할 필요가 없습니다.

DMA 컨트롤러는 복잡성이 매우 다양합니다. 위에서 언급했듯이 가장 간단한 접근 방식은 한 번에 하나의 전송을 처리하는 것입니다. 여러 전송을 동시에 처리하도록 보다 복잡한 프로그램을 프로그래밍할 수 있습니다. 이 유형의 컨트롤러에는 내부에 각 채널당 하나씩 여러 개의 레지스터 세트가 있습니다. CPU는 먼저 각 레지스터 세트와 전송을 위한 관련 매개변수를 로드합니다. 각 전송은 서로 다른 장치 컨트롤러를 사용해야 합니다. 위 다이어그램의 각 단어가 전송된 후(2~4단계) DMA 컨트롤러는 다음에 서비스할 장치를 결정합니다. 라운드 로빈 알고리즘을 사용하도록 설정될 수도 있고 특정 장치를 다른 장치보다 지원하도록 설계된 우선 순위 체계가 있을 수도 있습니다. 승인을 구별하는 명확한 방법이 있는 경우 서로 다른 장치 컨트롤러에 대한 여러 요청이 동시에 중단될 수 있습니다. 따라서 버스의 서로 다른 승인 라인이 일반적으로 각 DMA 채널에 사용됩니다.

많은 버스는 축어 모드와 블록 모드의 두 가지 모드로 작동할 수 있습니다. 일부 DMA 컨트롤러는 두 모드 모두에서 작동할 수도 있습니다. 이전 모드에서는 DMA 컨트롤러가 워드 전송을 요청하고 이를 얻습니다. CPU에도 버스가 필요한 경우 기다려야 합니다. 이 메커니즘을 사이클 스틸링이라고 합니다. 장치 컨트롤러가 CPU에 몰래 들어가 가끔 약간의 지연을 두고 CPU에서 버스 사이클을 훔치기 때문입니다. 블록 모드에서 DMA 컨트롤러는 장치에 버스를 획득하고 일련의 전송을 발행한 다음 버스를 해제하도록 지시합니다. 이러한 작동 형태를 버스트 모드라고 합니다. 버스를 획득하는 데 시간이 걸리고 버스 한 대의 가격으로 여러 단어를 전송할 수 있기 때문에 사이클 스틸링보다 더 효율적입니다. 버스트 모드의 단점은 긴 버스트를 전송할 경우 상당한 시간 동안 CPU 및 기타 장치를 차단할 수 있다는 것입니다.

우리가 논의하는 모델(플라이바이 모드라고도 함)에서 DMA 컨트롤러는 장치 컨트롤러에게 데이터를 메인 메모리로 직접 전송하라고 지시합니다. 일부 DMA 컨트롤러에서 사용되는 또 다른 패턴은 장치 컨트롤러가 Word를 DMA 컨트롤러로 보낸 다음 DMA 컨트롤러가 두 번째 버스 요청을 발행하여 Word를 쓰여져야 할 위치에 쓰는 것입니다. 이 방식은 전송되는 각 워드에 대해 추가 버스 사이클이 필요하지만 장치 간 복사 또는 메모리 간 복사도 수행할 수 있으므로 더 유연합니다(메모리를 먼저 읽은 다음 다른 주소의 메모리에 기록).

대부분의 DMA 컨트롤러는 전송을 위해 물리적 메모리 주소를 사용합니다. 물리적 주소를 사용하려면 운영 체제가 의도한 메모리 버퍼의 가상 주소를 물리적 주소로 변환하고 이 물리적 주소를 DMA 컨트롤러의 주소 레지스터에 써야 합니다. 몇몇 DMA 컨트롤러에서 사용되는 또 다른 방식은 DMA 컨트롤러에 가상 주소를 쓰는 것입니다.

그런 다음 DMA 컨트롤러는 MMU를 사용하여 가상-물리적 변환을 완료해야 합니다. 가상 주소는 MMU가 CPU의 일부가 아닌 메모리의 일부(가능하지만 드물게)인 경우에만 버스에 배치될 수 있습니다. 앞서 DMA가 시작되기 전에 디스크는 먼저 데이터를 내부 버퍼로 읽는다고 언급했습니다.

컨트롤러가 디스크에서 바이트를 가져온 후 메인 메모리에 직접 바이트를 저장하지 않는 이유는 무엇입니까? 즉, 왜 내부 버퍼가 필요한가요? 두 가지 이유가 있습니다.

첫째, 내부 버퍼링을 수행함으로써 디스크 컨트롤러는 전송을 시작하기 전에 체크섬을 확인할 수 있습니다. 체크섬이 올바르지 않으면 오류가 표시되고 전송이 발생하지 않습니다.

두 번째 이유는 디스크 전송이 시작되면 컨트롤러의 준비 여부에 관계없이 디스크에서 비트가 일정한 속도로 도착하기 때문입니다. 컨트롤러가 메모리에 직접 데이터를 쓰려고 시도하는 경우 각 단어는 시스템 버스를 통해 전송되어야 합니다. 다른 장치 사용(예: 버스트 모드)으로 인해 버스가 사용 중인 경우 컨트롤러는 기다려야 합니다. 이전 디스크 단어가 저장되기 전에 다음 디스크 단어가 도착하면 컨트롤러는 이를 어딘가에 저장해야 합니다. 버스가 바쁜 경우 컨트롤러는 꽤 많은 단어를 저장하고 관리 작업을 많이 해야 할 수 있습니다. 블록이 내부적으로 버퍼링되면 DMA가 시작될 때까지 버스가 필요하지 않으므로 DMA가 메모리로 전송되는 데 시간이 중요하지 않으므로 컨트롤러 설계가 훨씬 간단해집니다. (실제로 일부 구형 컨트롤러는 메모리에 직접 액세스하기 위해 소량의 내부 버퍼링만 필요하지만 버스가 매우 바쁜 경우 오버플로 오류로 인해 전송이 종료될 수 있습니다.)

모든 컴퓨터가 DMA를 사용하는 것은 아닙니다. 이에 반대되는 주장은 메인 CPU가 일반적으로 DMA 컨트롤러보다 훨씬 빠르며 작업을 더 빠르게 완료할 수 있다는 것입니다(제한 요소가 I/O 장치의 속도가 아닌 경우). 다른 작업이 없으면 (빠른) CPU가 (느린) DMA 컨트롤러가 완료될 때까지 기다리도록 하는 것은 의미가 없습니다. 또한 DMA 컨트롤러를 제거하고 CPU가 소프트웨어의 모든 작업을 수행하도록 하면 리소스가 절약되는데, 이는 저가형(내장형) 컴퓨터에서 중요합니다.

중단 다시 확인

일반적인 개인용 컴퓨터 시스템에서 인터럽트 구조는 아래 그림과 같습니다. 하드웨어 수준에서 인터럽트가 작동하는 방식은 다음과 같습니다. I/O 장치가 해당 작업을 완료하면 인터럽트가 발생합니다(운영 체제가 인터럽트를 활성화했다고 가정). 할당된 버스에 신호를 보내면 마더보드의 인터럽트 컨트롤러 칩이 이를 감지하고 무엇을 할지 결정합니다.

중단이 발생하는 방식. 장치와 컨트롤러 간의 연결은 실제로 전용 라인이 아닌 버스의 인터럽트 라인을 사용합니다.

보류 중인 다른 인터럽트가 없으면 인터럽트 컨트롤러는 즉시 인터럽트를 처리합니다. 그러나 다른 인터럽트가 진행 중이거나 다른 장치가 버스의 우선순위가 높은 인터럽트 요청 라인에 동시 요청을 발행하는 경우 해당 장치는 일시적으로 무시됩니다. 이 경우 CPU가 서비스를 제공할 때까지 버스에서 인터럽트 신호를 계속해서 주장합니다. 인터럽트를 처리하기 위해 컨트롤러는 주의가 필요한 장치를 지정하는 주소 라인에 숫자를 넣고 CPU를 인터럽트하라는 신호를 보냅니다.

인터럽트 신호로 인해 CPU는 하던 일을 멈추고 다른 일을 시작하게 됩니다. 주소 라인의 숫자는 새 프로그램 카운터를 얻기 위해 인터럽트 벡터라는 테이블에 대한 인덱스로 사용됩니다. 프로그램 카운터는 해당 인터럽트 서비스 루틴의 시작을 가리킵니다. 일반적으로 트랩과 인터럽트는 이 시점부터 동일한 메커니즘을 사용하며 종종 동일한 인터럽트 벡터를 공유합니다. 인터럽트 벡터의 위치는 머신에 직접 연결될 수도 있고, CPU 레지스터(운영 체제에 의해 로드됨)가 원점을 가리키는 메모리의 어느 위치에나 있을 수 있습니다.

실행이 시작된 직후, 인터럽트 서비스 루틴은 인터럽트 컨트롤러의 I/O 포트 중 하나에 값을 기록하여 인터럽트를 승인합니다. 이 승인은 컨트롤러에게 다른 인터럽트를 자유롭게 발행할 수 있음을 알립니다. 여러(거의 동시) 인터럽트와 관련된 경쟁 조건은 CPU가 다음 인터럽트를 처리할 준비가 될 때까지 이 승인을 지연시킴으로써 피할 수 있습니다. 또한 일부(구형) 컴퓨터에는 중앙 집중식 인터럽트 컨트롤러가 없으므로 각 장치 컨트롤러는 자체 인터럽트를 요청합니다.

하드웨어는 수리 절차를 시작하기 전에 항상 특정 정보를 저장합니다. 저장되는 정보와 저장 위치는 CPU마다 다릅니다. 중단된 프로세스를 다시 시작할 수 있도록 최소한 프로그램 카운터를 저장해야 합니다. 다른 극단적인 경우에는 표시되는 모든 레지스터와 다수의 내부 레지스터도 저장할 수 있습니다.

한 가지 질문은 이 정보를 어디에 저장할 것인가입니다. 한 가지 옵션은 운영 체제가 필요에 따라 읽을 수 있는 내부 레지스터에 넣는 것입니다. 이 접근 방식의 한 가지 문제점은 두 번째 인터럽트가 상태를 유지하는 내부 레지스터를 덮어쓰지 않도록 잠재적으로 관련된 모든 정보를 읽을 때까지 인터럽트 컨트롤러가 이를 승인할 수 없다는 것입니다. 이 전략을 사용하면 인터럽트가 비활성화된 경우 긴 데드존이 발생하고 인터럽트 손실 및 데이터 손실이 발생할 수 있습니다.

따라서 대부분의 CPU는 스택에 정보를 보관합니다. 그러나 이 접근 방식에는 문제가 있습니다. 첫째: 누구의 스택인가? 현재 스택이 사용되는 경우 사용자 프로세스 스택일 가능성이 높습니다. 스택 포인터는 합법적이지 않을 수도 있으며, 하드웨어가 지정된 주소에 어떤 단어를 쓰려고 할 때 치명적인 오류가 발생할 수 있습니다. 또한 페이지 끝을 가리킬 수도 있습니다. 다중 메모리 쓰기 후에는 페이지 경계가 초과되어 페이지 오류가 발생할 수 있습니다. 하드웨어 인터럽트 처리 중에 발생하는 페이지 오류는 더 큰 질문을 만듭니다. 페이지 오류를 처리하기 위해 상태가 어디에 저장되어 있습니까?

커널 스택을 사용하는 경우 스택 포인터가 합법적이고 고정된 페이지를 가리킬 가능성이 훨씬 더 높습니다. 그러나 커널 모드로 전환하려면 MMU 컨텍스트를 변경해야 할 수 있으며 대부분 또는 모든 캐시와 TLB가 무효화될 수 있습니다. 이 모든 것을 정적으로 또는 동적으로 다시 로드하면 인터럽트를 처리하는 데 걸리는 시간이 늘어나 CPU 시간이 낭비됩니다.

정확한 인터럽트와 부정확한 인터럽트

또 다른 문제는 대부분의 최신 CPU가 고도로 파이프라인화되어 있고 종종 수퍼스칼라(내부 병렬)라는 것입니다. 이전 시스템에서는 각 명령이 완료된 후 마이크로프로그램이나 하드웨어가 보류 중인 인터럽트가 있는지 확인합니다. 그렇다면 프로그램 카운터와 PSW가 스택에 푸시되고 인터럽트 시퀀스가 ​​시작됩니다. 인터럽트 핸들러가 실행된 후 역 프로세스가 발생하고 이전 PSW 및 프로그램 카운터가 스택에서 제거되고 이전 프로세스가 계속됩니다.

모델은 명령어 이후에 인터럽트가 발생하면 해당 명령어 이전 및 포함된 모든 명령어가 완전히 실행되었으며 그 이후에는 명령어가 전혀 없다고 암묵적으로 가정합니다. 이전 시스템에서는 이 가정이 항상 유효합니다. 최신 장치에서는 그렇지 않을 수도 있습니다.

파이프가 가득 찼을 때 정전이 발생하면 일반적으로 어떻게 됩니까? 많은 명령이 서로 다른 실행 단계에 있습니다. 인터럽트가 발생하면 프로그램 카운터 값이 실행된 명령어와 실행되지 않은 명령어 간의 올바른 경계를 반영하지 못할 수 있습니다. 실제로 많은 명령어가 부분적으로 실행될 수 있으며, 다른 명령어는 다소 완전합니다. 이 경우 프로그램 카운터는 실행 장치에서 방금 처리한 명령어의 주소가 아니라 파이프라인으로 가져와서 푸시할 다음 명령어의 주소를 반영할 가능성이 높습니다.

슈퍼스칼라 머신에서는 상황이 더욱 악화됩니다. 명령어는 기능 단위 및 레지스터와 같은 내부 리소스의 가용성에 따라 순서 없이 실행될 수 있는 마이크로 작업으로 분해될 수 있습니다. 인터럽트 시점에 일찍 시작된 일부 명령은 아직 시작되지 않았을 수 있지만 최근에 시작된 다른 명령은 거의 완료되었습니다. 인터럽트가 신호를 받을 때 서로 다른 완료 상태의 많은 명령이 있을 수 있으며 이는 프로그램 카운터와 관련이 적습니다.

머신을 잘 정의된 상태로 만드는 인터럽트를 정밀 인터럽트라고 하며 다음과 같은 네 가지 속성을 갖습니다.

  1. PC(프로그램 카운터)는 알려진 위치에 저장됩니다.

  2. PC가 가리키는 지시 이전의 모든 지시가 완료되었습니다.

  3. PC에서 지시한 것 외에는 다른 지시가 완료되지 않습니다.

  4. PC가 가리키는 명령의 실행 상태를 알 수 있습니다.

컴퓨터에서 지시한 것 외에는 시작을 비활성화하는 지침이 없다는 점에 유의하십시오. 레지스터나 메모리에 대한 모든 변경 사항은 인터럽트가 발생하기 전에 실행 취소되어야 합니다. 지정된 명령이 실행되도록 허용합니다. 또한 허용(allowed)은 아직 실행되지 않았습니다.

어떤 경우가 적용되는지 명확해야 합니다. 일반적으로 인터럽트가 I/O 인터럽트인 경우 명령이 아직 시작되지 않았습니다. 그러나 인터럽트가 실제로 트랩이나 페이지 오류인 경우 PC는 일반적으로 나중에 다시 시작하기 위해 오류를 일으킨 명령을 가리킵니다. 아래 그림 (a)의 상황은 정확한 인터럽트를 보여줍니다. 프로그램 카운터(316) 이전의 모든 명령어는 완료되었으며 그 이후의 명령어는 시작되지 않았습니다(또는 해당 효과를 취소하기 위해 롤백).

이러한 요구 사항을 충족하지 않는 인터럽트를 부정확한 인터럽트라고 하며, 이제 무슨 일이 일어났는지, 또 어떤 일이 일어날지 파악해야 하는 운영 체제 작성자의 삶을 가장 불쾌하게 만듭니다. 아래 그림 (b)는 부정확한 인터럽트를 보여줍니다. 프로그램 카운터 근처의 여러 명령어가 서로 다른 완료 단계에 있고 이전 명령어가 반드시 새 명령어보다 더 완전하지는 않습니다. 부정확한 인터럽트가 있는 머신은 운영 체제가 무슨 일이 일어나고 있는지 파악할 수 있도록 많은 내부 상태를 스택에 내보내는 경우가 많습니다. 머신을 재부팅하는 데 필요한 코드는 매우 복잡한 경우가 많습니다. 또한 각 인터럽트마다 많은 양의 정보가 메모리에 저장되므로 인터럽트가 느려지고 복구가 더 어려워집니다. 이로 인해 매우 빠른 슈퍼스칼라 CPU가 느린 인터럽트 속도로 인해 실시간 작업에 적합하지 않은 아이러니한 상황이 발생합니다.

일부 컴퓨터는 특정 인터럽트와 트랩이 정확하도록 설계되었지만 다른 컴퓨터는 그렇지 않습니다. 예를 들어, I/O 인터럽트는 정확하지만 치명적인 프로그래밍 오류로 인한 트랩은 부정확합니다. 이는 실행 중인 프로세스를 0으로 나눈 후 다시 시작하려고 시도할 필요가 없기 때문에 그렇게 나쁘지 않습니다. 일부 시스템에는 모든 인터럽트를 정확하게 강제로 설정할 수 있는 비트가 있습니다. 이 비트 설정의 단점은 CPU가 수행 중인 모든 작업을 주의 깊게 기록하고 레지스터의 섀도 복사본을 유지하여 언제든지 정확한 인터럽트를 생성할 수 있다는 것입니다. 이 모든 오버헤드는 성능에 상당한 영향을 미칠 수 있습니다.

(a) 정확한 중단; (b) 부정확한 중단.

x86 시리즈와 같은 일부 수퍼스칼라 컴퓨터에는 이전 소프트웨어가 제대로 작동할 수 있도록 정확한 인터럽트가 있습니다. 정확한 인터럽트와의 역호환성을 위해 지불된 대가는 인터럽트 컨트롤러가 인터럽트가 발생해야 한다는 신호를 보낼 때 모든 명령이 특정 지점 이전에 완료될 수 있고 해당 지점 이후의 명령이 시스템 상태에 눈에 띄는 영향을 미치지 않도록 허용하기 위한 CPU 내의 매우 복잡한 인터럽트 논리입니다. 여기서 지불되는 대가는 시간이 아니라 칩 면적과 설계 복잡성입니다. 이전 버전과의 호환성을 위해 정확한 인터럽트가 필요하지 않은 경우 칩의 이 영역을 더 큰 온칩 캐시에 사용하여 CPU를 더 빠르게 만들 수 있습니다. 반면, 부정확한 인터럽트는 운영 체제를 더 복잡하고 느리게 만들기 때문에 어떤 접근 방식이 실제로 더 나은지 말하기가 어렵습니다.

18.11.4.2 I/O 소프트웨어 원리

이 섹션에서는 I/O의 목표를 설명하고 운영 체제 관점에서 다양한 구현을 살펴봅니다.

I/O 소프트웨어의 목표

I/O 소프트웨어 설계의 핵심 개념은 장치 독립성입니다. 즉, 사전에 장치를 지정하지 않고도 모든 I/O 장치에 액세스할 수 있는 프로그램을 작성할 수 있어야 한다는 의미입니다. 예를 들어, 파일을 입력으로 읽는 프로그램은 각각의 다른 장치에 대해 파일을 수정하지 않고도 하드 드라이브, DVD 또는 USB 플래시 드라이브에 있는 파일을 읽을 수 있어야 합니다. 마찬가지로 다음 명령을 입력할 수 있어야 합니다.

cpp
sort <input> output

모든 디스크나 키보드의 입력을 처리하고 모든 디스크나 화면으로 전송되는 출력을 처리할 수 있습니다. 이러한 장치는 실제로 다르며 읽거나 쓰려면 매우 다른 명령 시퀀스가 ​​필요하며 이러한 문제를 해결하는 것은 운영 체제에 달려 있습니다.

장치 독립성과 밀접한 관련이 있는 것은 통일된 명명의 목표입니다. 파일이나 장치의 이름은 문자열이나 정수여야 하며 어떤 방식으로든 장치에 종속되어서는 안 됩니다. UNIX에서는 모든 디스크가 어떤 방식으로든 파일 시스템 계층 구조에 통합될 수 있으므로 사용자는 어떤 이름이 어떤 장치에 해당하는지 알 필요가 없습니다. 예를 들어 USB 스틱을 /usr/ast/backup 디렉터리 상단에 마운트하면 파일을 /usr/ast/backup/monday에 복사하여 USB 스틱에 파일을 복사할 수 있습니다. 이런 방식으로 모든 파일과 장치는 경로 이름으로 동일한 방식으로 처리됩니다.

I/O 소프트웨어의 또 다른 중요한 문제는 오류 처리입니다. 일반적으로 오류 처리는 하드웨어와 최대한 유사해야 합니다. 컨트롤러가 읽기 오류를 감지하면 가능하면 오류 자체를 수정하려고 시도해야 합니다. 그럴 수 없다면 장치 드라이버가 이를 처리하고 블록을 다시 읽으려고 시도해야 합니다. 읽기 헤드의 먼지 얼룩으로 인해 발생하는 읽기 오류와 같은 많은 오류는 일시적이며 작업을 반복하면 일반적으로 사라집니다. 하급자가 문제를 처리할 수 없는 경우에만 상급자에게 알려야 합니다. 많은 경우 오류 복구는 상위 계층이 오류를 인식하지 못한 채 하위 수준에서 투명하게 수행될 수 있습니다.

또 다른 중요한 문제는 동기(차단) 전송과 비동기(인터럽트 구동) 전송입니다. 대부분의 물리적 I/O는 비동기식입니다. CPU는 전송을 시작한 다음 인터럽트가 발생할 때까지 다른 작업을 수행합니다. 읽기 시스템 호출 후 I/O 작업이 차단되면 사용자 프로그램에서 쓰기가 더 쉽고, 버퍼에서 데이터를 사용할 수 있을 때까지 프로그램이 자동으로 정지됩니다. 운영 체제는 인터럽트 기반 작업이 사용자 프로그램을 차단하는 것처럼 보이도록 해야 합니다. 그러나 일부 매우 고성능 애플리케이션은 I/O의 모든 세부 사항을 제어해야 하므로 일부 운영 체제는 이를 위해 비동기 I/O를 제공합니다.

I/O 소프트웨어의 또 다른 문제는 버퍼링입니다. 장치에서 나오는 데이터를 최종 목적지에 직접 저장할 수 없는 경우가 많습니다. 예를 들어, 네트워크에서 패킷이 들어오면 운영 체제는 패킷을 어딘가에 저장하고 검사할 때까지 패킷을 어디에 넣어야 할지 알 수 없습니다. 또한 일부 장치에는 심각한 실시간 제한(예: 디지털 오디오 장치)이 있으므로 버퍼 부족을 방지하려면 버퍼 채우기 속도를 빈 속도에서 분리하기 위해 미리 데이터를 출력 버퍼에 넣어야 합니다. 버퍼링에는 많은 복사가 포함되며 I/O 성능에 상당한 영향을 미치는 경우가 많습니다. 여기서 다루고 싶은 마지막 개념은 공유 장치와 전용 장치입니다.

일부 I/O 장치(예: 디스크)는 많은 사용자가 동시에 사용할 수 있으며 여러 사용자가 동시에 동일한 디스크에 있는 파일을 열어도 문제가 발생하지 않습니다. 프린터와 같은 다른 장치는 해당 사용자가 작업을 완료할 때까지 단일 사용자에게만 할당되어야 하며 그 이후에는 다른 사용자가 프린터를 소유할 수 있습니다. 두 명 이상의 사용자가 같은 페이지에 임의의 문자 조합을 작성하도록 하는 것은 확실히 작동하지 않습니다. 전용(비공유) 장치를 도입하면 교착 상태와 같은 다양한 문제도 발생합니다. 마찬가지로 운영 체제는 문제를 방지하는 방식으로 공유 및 전용 장치를 처리할 수 있어야 합니다.

프로그래밍 입력/출력

I/O를 수행하는 데는 근본적으로 다른 세 가지 방법이 있습니다. I/O의 가장 간단한 형태는 CPU가 모든 작업을 수행하도록 하는 것입니다. 이 방법을 프로그래밍된 I/O라고 합니다.

I/O).

예제를 통해 프로그래밍 I/O가 어떻게 작동하는지 설명하는 것이 가장 쉽습니다. 직렬 인터페이스를 통해 프린터에 8자 문자열 "ABCDEFGH"를 인쇄하려는 사용자 프로세스를 생각해 보십시오. 소프트웨어는 먼저 아래 그림 (a)와 같이 사용자 공간의 버퍼에 문자열을 어셈블합니다.

그런 다음 사용자 프로세스는 시스템 호출을 통해 프린터를 열어 프린터에 쓸 수 있도록 합니다. 현재 다른 프로세스에서 프린터를 사용 중인 경우 운영 체제 및 호출 매개변수에 따라 이 호출은 오류 코드와 함께 실패하거나 프린터를 사용할 수 있을 때까지 차단됩니다. 프린터가 있으면 사용자 프로세스는 운영 체제에 프린터에 문자열을 인쇄하도록 지시하는 시스템 호출을 수행합니다.

그런 다음 운영 체제는 (보통) 문자열이 포함된 버퍼를 커널 공간(예: p)의 배열에 복사합니다. 여기서는 액세스하기가 더 쉽습니다(커널이 사용자 공간에 도달하기 위해 메모리 맵을 변경해야 할 수 있으므로). 그런 다음 프린터를 현재 사용할 수 있는지 확인하고, 그렇지 않은 경우 프린터를 사용할 수 있을 때까지 기다립니다. 프린터를 사용할 수 있게 되면 운영 체제는 메모리 매핑된 I/O를 사용하여 첫 번째 문자를 프린터의 데이터 레지스터에 복사하여 프린터를 활성화합니다.

일부 프린터에서는 인쇄하기 전에 줄이나 페이지를 버퍼링하기 때문에 이 문자가 아직 나타나지 않을 수 있습니다. 그러나 아래 (b)에서는 첫 번째 문자가 인쇄되고 시스템이 인쇄할 다음 문자로 "b"를 표시한 것을 볼 수 있습니다.

첫 번째 문자가 프린터에 복사되면 운영 체제는 프린터가 다른 문자를 받아들일 준비가 되었는지 확인합니다. 일반적으로 프린터에는 상태를 표시하는 두 번째 레지스터가 있으며, 데이터 레지스터에 쓰는 작업으로 인해 상태가 준비 안 됨으로 변경됩니다. 프린터 컨트롤러가 현재 문자 처리를 마치면 상태 레지스터에 일부 비트를 설정하거나 거기에 일부 값을 입력하여 해당 문자의 가용성을 나타냅니다.

이 시점에서 운영 체제는 프린터가 다시 준비될 때까지 기다립니다. 이런 일이 발생하면 아래 (c)와 같이 다음 문자를 인쇄합니다. 이 루프는 전체 문자열이 인쇄될 때까지 계속된 다음 제어가 사용자 프로세스로 반환됩니다.

문자열을 인쇄하는 단계.

다음 의사코드는 운영 체제에서 수행되는 작업을 간략하게 요약합니다. 먼저 데이터가 커널에 복사된 다음 운영 체제는 한 번에 한 문자를 출력하는 긴밀한 루프에 들어갑니다. I/O 프로그래밍의 기본 동작은 문자를 출력한 후 CPU가 계속해서 장치를 폴링하여 다른 문자를 받아들일 준비가 되었는지 확인하는 것입니다. 이러한 동작을 흔히 폴링 또는 바쁜 대기라고 합니다.

cpp
copy_from_user(buffer, p,count); /* p is the ker nel buffer */

for (i = 0; i < count; i++)      /* loop on every character */
{ 
    while (*printer_status_reg != READY) ; /* loop until ready */
    *printer_data_register = p[i];         /* output one character */
}

return_to_user( );

I/O 프로그래밍은 쉽지만, 모든 I/O가 완료되기 전에 CPU의 시간을 모두 잡아먹는 단점이 있습니다. 문자를 "인쇄"하는 데 걸리는 시간이 짧다면(프린터가 하는 일은 새 문자를 내부 버퍼에 복사하는 것뿐이므로) 바쁜 대기는 괜찮습니다. 게다가 임베디드 시스템에서는 CPU가 다른 일을 할 필요가 없고 바쁜 대기(Busy Waiting)도 괜찮습니다. 그러나 더 복잡한 시스템에서는 CPU가 수행해야 할 다른 작업이 있으므로 바쁜 대기는 비효율적이며 더 나은 I/O 방법이 필요합니다.

인터럽트 기반 I/O

이제 프린터에서 인쇄하는 경우를 고려해 보겠습니다. 프린터는 문자를 버퍼링하지 않고 도착하는 대로 각 문자를 인쇄합니다. 프린터가 초당 100자 인쇄할 수 있다면 각 문자를 인쇄하는 데 10ms가 걸립니다. 이는 각 문자가 프린터의 데이터 레지스터에 기록된 후 CPU가 10밀리초 동안 유휴 루프에 머물며 다음 문자가 출력될 때까지 대기한다는 의미입니다. 10밀리초 이내에 컨텍스트 전환을 수행하고 잠재적으로 낭비될 수 있는 다른 프로세스를 실행하기에 충분합니다.

프린터가 준비되기를 기다리는 동안 CPU가 다른 작업을 수행할 수 있도록 하는 방법은 인터럽트를 사용하는 것입니다. 시스템 호출이 문자열을 인쇄하면 버퍼가 커널 공간에 복사되고, 앞에서 설명한 것처럼 프린터가 문자를 허용하는 한 첫 번째 문자가 프린터에 복사됩니다. 이 시점에서 CPU는 스케줄러를 호출하고 다른 프로세스를 실행합니다. 문자열을 인쇄해야 하는 프로세스는 전체 문자열이 인쇄될 때까지 차단됩니다. 시스템 호출의 작동은 아래 (a)에 나와 있습니다.

프린터가 문자 인쇄를 마치고 다음 문자를 받아들일 준비가 되면 현재 프로세스를 중지하고 상태를 저장한 다음 프린터 인터럽트 서비스 루틴을 실행하는 인터럽트를 생성합니다. 이 코드의 대략적인 버전은 아래 (b)에 나와 있습니다. 더 이상 인쇄할 문자가 없으면 인터럽트 처리기는 사용자 차단을 해제하기 위한 조치를 취합니다. 그렇지 않으면 다음 문자를 인쇄하고, 인터럽트를 승인하고, 인터럽트 이전에 실행 중이던 프로세스로 돌아가서 중단된 부분부터 계속됩니다.

cpp
// 을(를) 활용하여 인터럽트 I/O기록/쓰기(Write)。

// (a) 실행(Execute)호출(Call)실행(Execute)의 코드 。
copy_from_user(buffer, p, count); 
enable_interrupts(); 
while (*printer_status_reg != READY) ; 
*printer_data_register = p[0]; 
scheduler(); 
    
// (b) 인터럽트 의 。
if (count == 0) 
{
    unblock_user( );
} 
else 
{
    *printer_data_register = p[i];
    count = count − 1;
    i = i + 1;
}
acknowledge_interrupt();
return_from_interrupt();

DMA를 사용한 I/O

인터럽트 중심 I/O의 명백한 단점은 모든 문자에 대해 인터럽트가 발생하고 인터럽트에 시간이 걸리기 때문에 이 솔루션은 일정량의 CPU 시간을 낭비한다는 것입니다. 해결책은 DMA를 사용하고 DMA 컨트롤러가 CPU에 영향을 주지 않고 한 번에 하나씩 프린터에 문자를 입력하도록 하는 것입니다. 기본적으로 DMA는 프로그래밍된 I/O이며 메인 CPU가 아닌 DMA 컨트롤러만 모든 작업을 수행합니다. 이 전략에는 특수 하드웨어(DMA 컨트롤러)가 필요하지만 I/O 중에 CPU를 해제하여 다른 작업을 수행합니다. 코드 요약은 아래와 같습니다.

cpp
// 을(를) 활용하여 DMA。

// (a) 실행(Execute)호출(Call)실행(Execute)의 코드 。
copy_from_user(buffer, p, count); 
set_up_DMA_controller(); 
scheduler(); 

// (b) 인터럽트 。
acknowledge_interrupt();
unblock_user();
return_from_interrupt();

DMA의 가장 큰 장점은 문자당 인터럽트 수를 인쇄 버퍼당 하나로 줄이는 것입니다. 이는 문자가 많고 인터럽트가 느린 경우 큰 개선입니다. 반면에 DMA 컨트롤러는 일반적으로 메인 CPU보다 훨씬 느립니다. DMA 컨트롤러가 장치를 최대 속도로 구동할 수 없거나 CPU가 일반적으로 DMA 인터럽트를 기다리는 동안 아무 작업도 수행하지 않는 경우에는 인터럽트 구동 I/O 또는 프로그래밍된 I/O가 더 나을 수 있습니다. 그러나 대부분의 경우 DMA는 그만한 가치가 있습니다.

전통적인 DMA 블록 다이어그램.

향상된 DMA 구성.

18.11.4.3 I/O 소프트웨어 수준

I/O 소프트웨어는 일반적으로 아래 그림과 같이 4개의 계층으로 구분됩니다. 각 계층에는 수행할 잘 정의된 기능이 있으며 인접 계층과의 잘 정의된 인터페이스가 있습니다. 기능과 인터페이스는 시스템마다 다르므로 다음 설명(맨 아래에서 시작하여 모든 레이어 검토)은 특정 시스템에만 국한되지 않습니다.

I/O 소프트웨어 시스템의 계층.

이러한 레이어는 아래에 설명되어 있습니다.

  • 인터럽트 핸들러

프로그래밍된 I/O는 때때로 유용하지만 대부분의 I/O에서 인터럽트는 피할 수 없는 불쾌한 사실입니다. 가능한 한 소수의 운영 체제가 이에 대해 알 수 있도록 운영 체제 내부 깊숙이 숨겨야 합니다. 이를 숨기는 가장 좋은 방법은 I/O가 완료되고 인터럽트가 발생할 때까지 드라이버가 I/O 작업 블록을 시작하도록 하는 것입니다. 예를 들어 드라이버는 세마포어 닫기, 조건 변수 대기, 메시지 수신 또는 유사한 작업을 통해 자신을 차단할 수 있습니다. 인터럽트가 발생하면 인터럽트 프로세스는 인터럽트를 처리하기 위해 수행해야 하는 모든 작업을 수행한 다음 이를 기다리고 있는 드라이버의 잠금을 해제할 수 있습니다.

어떤 경우에는 세마포어에서만 수행됩니다. 다른 경우에는 모니터에 조건 변수를 표시합니다. 다른 경우에는 차단된 드라이버에게 메시지를 보냅니다. 모든 경우에 인터럽트의 최종 효과는 이전에 차단된 드라이버가 이제 실행될 수 있다는 것입니다. 이 모델은 드라이버가 커널 프로세스로 구성되어 있고 자체 상태, 스택 및 프로그램 카운터가 있는 경우 가장 잘 작동합니다.

물론 현실은 그렇게 간단하지 않다. 인터럽트를 처리하는 것은 단순히 인터럽트를 수락하고 일부 세마포어를 작동시킨 다음 IRET 명령어를 실행하여 인터럽트에서 이전 프로세스로 돌아가는 것 이상입니다. 운영 체제는 더 많은 작업을 수행해야 합니다. 이제 이 작업은 하드웨어 인터럽트가 완료된 후 소프트웨어에서 수행해야 하는 일련의 단계로 설명됩니다. 이러한 세부 정보는 시스템에 따라 크게 달라지므로 아래 나열된 일부 단계는 특정 시스템에 필요하지 않거나 나열되지 않은 단계가 필요할 수 있습니다. 또한 일부 시스템에서는 발생하는 단계의 순서가 다를 수 있습니다.

  1. 인터럽트 하드웨어에 의해 저장되지 않은 모든 레지스터(PSW 포함)를 저장합니다.

  2. 인터럽트 서비스 프로세스에 대한 컨텍스트를 설정합니다. TLB, MMU 및 페이지 테이블을 설정해야 할 수도 있습니다.

  3. 인터럽트 서비스 프로세스를 위한 스택을 설정합니다.

  4. 인터럽트 컨트롤러를 확인하십시오. 중앙 집중식 인터럽트 컨트롤러가 없으면 인터럽트를 다시 활성화합니다.

  5. 저장된 위치(아마도 일부 스택)의 레지스터를 프로세스 테이블에 복사합니다.

  6. 인터럽트 장치 컨트롤러의 레지스터에서 정보를 추출하는 인터럽트 서비스 루틴을 실행합니다.

  7. 실행할 다음 프로세스를 선택합니다. 인터럽트로 인해 차단된 우선순위가 높은 프로세스가 준비되면 즉시 실행하도록 선택할 수 있습니다.

  8. 다음 프로세스가 실행될 수 있도록 MMU 컨텍스트를 설정합니다. 일부 TLB 설정이 필요할 수도 있습니다.

  9. PSW를 포함하여 새 프로세스의 레지스터를 로드합니다.

  10. 새 프로세스 실행을 시작합니다.

보시다시피, 인터럽트 처리는 결코 사소한 일이 아닙니다. 또한 특히 가상 메모리가 존재하고 페이지 테이블을 설정해야 하거나 MMU 상태(예: R 및 M 비트)를 저장해야 하는 시스템에서는 꽤 많은 CPU 명령이 필요합니다. 일부 시스템에서는 추가 시스템 주기가 필요한 사용자 모드와 커널 모드 간 전환 시 TLB 및 CPU 캐시를 관리해야 할 수도 있습니다.

  • 장치 드라이버

앞서 우리는 장치 컨트롤러의 기능에 대해 논의했습니다. 각 컨트롤러에는 명령을 실행하기 위한 일부 장치 레지스터나 상태를 읽기 위한 일부 장치 레지스트리 또는 두 가지가 모두 있는 것을 볼 수 있습니다. 장치 레지스터의 수와 명령의 성격은 장치마다 다릅니다. 예를 들어, 마우스 드라이버는 마우스로부터 정보를 받아 마우스가 얼마나 이동했는지, 현재 어떤 버튼을 누르고 있는지 알려주어야 합니다. 대신 디스크 드라이브는 섹터, 트랙, 실린더, 헤드, 암 동작, 모터 드라이브, 헤드 고정 시간 및 디스크가 제대로 작동하도록 하는 기타 모든 메커니즘을 이해해야 할 수 있습니다. 분명히 이러한 드라이버는 매우 다를 것입니다.

따라서 컴퓨터에 연결된 모든 I/O 장치에는 이를 제어하기 위한 일부 장치별 코드가 필요합니다. 이 코드는 장치 드라이버라고 하며 일반적으로 장치 제조업체에서 작성하여 장치와 함께 제공됩니다. 각 운영 체제에는 자체 드라이버가 필요하므로 장치 제조업체는 일반적으로 널리 사용되는 여러 운영 체제에 대한 드라이버를 제공합니다.

각 장치 드라이버는 일반적으로 하나의 장치 유형 또는 최대 하나의 밀접하게 관련된 장치 클래스를 처리합니다. 예를 들어, SCSI 디스크 드라이버는 SCSI Blu-ray 디스크뿐만 아니라 다양한 크기와 속도의 여러 SCSI 디스크를 처리할 수 있는 경우가 많습니다. 반면, 마우스와 조이스틱은 너무 다르기 때문에 종종 다른 드라이버가 필요합니다. 그러나 단일 장치 드라이버가 관련되지 않은 여러 장치를 제어하는 ​​데는 기술적인 제한이 없으며 대부분의 경우 좋은 생각이 아닙니다.

그러나 때로는 서로 다른 장치가 동일한 기본 기술을 기반으로 하는 경우도 있습니다. 가장 유명한 예는 아마도 USB일 것입니다. 이는 괜히 "범용"이라고 불리지 않는 직렬 버스 기술입니다. USB 장치에는 디스크, 메모리 스틱, 카메라, 마우스, 키보드, 미니 팬, 무선 카드, 로봇, 신용 카드 리더기, 충전식 면도기, 분쇄기, 바코드 스캐너, 디스코 볼 및 휴대용 온도계가 포함됩니다. 둘 다 USB를 사용하지만 수행하는 작업은 매우 다릅니다.

비결은 USB 드라이버가 일반적으로 네트워크의 TCP/IP 스택처럼 스택된다는 것입니다. 일반적으로 하드웨어의 최하위 수준에서는 신호를 USB 패킷으로 전송하고 신호 스트림을 디코딩하는 등의 하드웨어를 처리하는 USB 링크 계층(직렬 I/O)을 찾을 수 있습니다. 이는 패킷의 높은 수준 처리뿐만 아니라 대부분의 장치에서 공유되는 USB 일반 기능에도 사용됩니다. 그 외에도 마지막으로 대용량 저장 인터페이스, 카메라 등과 같은 더 높은 수준의 API가 있습니다. 따라서 프로토콜 스택의 일부를 공유하더라도 여전히 별도의 장치 드라이버가 있습니다.

실제로 장치의 하드웨어, 즉 컨트롤러의 레지스터에 액세스하려면 적어도 현재 아키텍처에서는 일반적으로 장치 드라이버가 운영 체제 커널의 일부여야 합니다. 실제로 사용자 공간에서 실행되고 시스템 호출을 통해 장치 레지스터를 읽고 쓰는 드라이버를 구성하는 것이 가능합니다. 이 설계는 드라이버에서 커널을 분리하고 드라이버를 서로 분리하여 어떤 방식으로든 커널을 방해하는 버그 드라이버를 충돌시키는 주요 소스를 제거합니다. 이는 확실히 신뢰성이 높은 시스템을 구축하기 위한 방법입니다. 장치 드라이버가 사용자 프로세스로 실행되는 시스템의 예는 MINIX 3입니다. 그러나 대부분의 다른 데스크톱 운영 체제에서는 드라이버가 커널에서 실행되기를 기대하므로 여기서는 이 모델을 고려할 것입니다.

모든 운영 체제의 설계자는 외부에서 작성된 코드(드라이버)가 내부에 설치된다는 것을 알고 있으므로 이러한 설치를 허용하는 아키텍처가 필요합니다. 즉, 드라이버의 기능과 드라이버가 운영 체제의 나머지 부분과 상호 작용하는 방식을 설명하는 잘 정의된 모델이 있어야 합니다. 장치 드라이버는 일반적으로 아래 이미지에 표시된 것처럼 나머지 운영 체제 아래에 위치합니다.

장치 드라이버의 논리적 위치. 드라이버와 장치 컨트롤러 간의 거의 모든 통신은 버스를 통해 이루어집니다.

운영 체제는 일반적으로 드라이버를 몇 가지 범주 중 하나로 분류합니다. 가장 일반적인 범주는 독립적으로 주소를 지정할 수 있는 여러 데이터 블록을 포함하는 블록 장치(예: 디스크)와 문자 스트림을 생성하거나 허용하는 문자 장치(예: 키보드 및 프린터)입니다.

대부분의 운영 체제는 모든 블록 드라이버가 지원해야 하는 표준 인터페이스와 모든 문자 드라이버가 지원해야 하는 두 번째 표준 인터페이스를 정의합니다. 이러한 인터페이스는 드라이버가 작동하도록 운영 체제의 다른 부분에서 호출할 수 있는 여러 프로시저로 구성됩니다. 일반적인 단계는 블록 읽기(블록 장치) 또는 문자열 쓰기(문자 장치)입니다.

일부 시스템에서 운영 체제는 컴파일해야 하는 모든 드라이버를 포함하는 단일 바이너리 프로그램입니다. 이 체계는 UNIX 시스템이 컴퓨터 센터에서 실행되고 I/O 장치가 거의 변경되지 않기 때문에 수년 동안 UNIX 시스템의 표준이었습니다. 새 장치가 추가되면 시스템 관리자는 새 드라이버로 커널을 다시 컴파일하여 새 바이너리를 만들 수 있습니다.

개인용 컴퓨터와 수많은 I/O 장치의 출현으로 이 모델은 더 이상 유효하지 않게 되었습니다. 소스 코드나 개체 모듈이 있어도 커널을 다시 컴파일하거나 다시 링크할 수 있는 사용자는 거의 없지만 항상 그런 것은 아닙니다. 대신 MS-DOS로 시작하는 운영 체제는 실행 중에 드라이버가 시스템에 동적으로 로드되는 모델로 이동했습니다. 시스템마다 드라이버 로드를 다르게 처리합니다.

장치 드라이버에는 여러 가지 기능이 있습니다. 가장 확실한 방법은 상위 장치 독립적 소프트웨어의 추상적인 읽기 및 쓰기 요청을 수락하고 실행되도록 하는 것입니다. 그러나 일부 다른 기능도 수행해야 합니다. 예를 들어 드라이버는 필요한 경우 장치를 초기화해야 합니다. 또한 전원 요구 사항을 관리하고 이벤트를 기록해야 할 수도 있습니다.

많은 장치 드라이버는 비슷한 일반 구조를 가지고 있습니다. 일반적인 드라이버는 먼저 입력 매개변수가 유효한지 확인하고, 그렇지 않은 경우 오류를 반환하며, 유효한 경우 추상 용어를 구체적인 용어로 변환해야 할 수도 있습니다. 디스크 드라이브의 경우 이는 선형 블록 번호를 디스크 구조에 대한 헤드, 트랙, 섹터 및 실린더 번호로 변환하는 것을 의미할 수 있습니다.

다음으로, 운전자는 해당 장치가 현재 사용 중인지 확인할 수 있습니다. 그렇다면 요청은 나중에 처리하기 위해 대기열에 들어가고, 장치가 유휴 상태이면 하드웨어 상태를 확인하여 지금 요청을 처리할 수 있는지 확인합니다. 전송을 시작하기 전에 장비를 켜거나 모터를 시동해야 할 수 있으며, 장비가 준비되면 실제 제어가 시작될 수 있습니다.

장치를 제어한다는 것은 장치에 일련의 명령을 내리는 것을 의미합니다. 드라이버는 수행해야 하는 작업에 따라 명령 시퀀스에서 수행해야 하는 위치를 결정합니다. 드라이버는 실행할 명령을 알고 나면 해당 명령을 컨트롤러의 장치 레지스터에 쓰기 시작합니다. 각 명령이 컨트롤러에 기록된 후 컨트롤러가 명령을 수락했고 다음 명령을 수락할 준비가 되었는지 확인해야 할 수도 있습니다. 이 순서는 모든 명령이 실행될 때까지 계속됩니다. 일부 컨트롤러는 연결된 명령 목록(메모리 내)을 가져오고 운영 체제의 추가 도움 없이 모든 명령을 자체적으로 읽고 처리하도록 지시할 수 있습니다.

명령이 실행되면 다음 두 조건 중 하나가 적용됩니다. 많은 경우 장치 드라이버는 컨트롤러가 일부 작업을 수행할 때까지 기다려야 하므로 인터럽트가 차단을 해제할 때까지 자체적으로 차단됩니다. 그러나 다른 경우에는 작업이 즉시 완료되므로 드라이버가 차단할 필요가 없습니다. 후자의 경우의 예로서, 화면을 스크롤하려면 컨트롤러의 레지스터에 몇 바이트만 쓰면 됩니다. 기계적 움직임이 필요하지 않으므로 전체 작업이 나노초 내에 완료될 수 있습니다.

전자의 경우 차단된 드라이버는 인터럽트에 의해 깨어납니다. 후자의 경우 절대 잠들지 않습니다. 그럼에도 불구하고 드라이버는 작업이 완료된 후 오류를 확인해야 합니다. 모든 것이 정상이라면 드라이버에는 장치 독립적인 소프트웨어에 전달할 일부 데이터(예: 방금 읽은 블록)가 있을 것입니다.

마지막으로 오류가 호출자에게 보고될 수 있도록 일부 상태 정보를 반환합니다. 대기 중인 다른 요청이 있는 경우 이제 그 중 하나를 선택하여 시작할 수 있습니다. 대기열에 아무것도 없으면 드라이버는 다음 요청을 기다리는 것을 차단합니다. 이 단순한 모델은 현실의 대략적인 근사치일 뿐입니다. 많은 요인이 코드를 더욱 복잡하게 만듭니다. 첫째, 드라이버가 실행되는 동안 I/O 장치가 완료되어 드라이버를 중단할 수 있으며, 인터럽트로 인해 장치 드라이버가 실행될 수 있습니다. 실제로 이로 인해 현재 드라이버가 실행될 수 있습니다. 예를 들어 네트워크 드라이버가 들어오는 패킷을 처리하는 동안 다른 패킷이 도착할 수 있습니다. 따라서 드라이버는 재진입 가능해야 합니다. 즉, 실행 중인 드라이버는 첫 번째 호출이 완료되기 전에 두 번째 호출을 예상해야 합니다.

핫플러그 시스템에서는 컴퓨터가 실행되는 동안 장치를 추가하거나 제거할 수 있습니다. 따라서 운전자가 장치를 읽는 중일 때 시스템은 사용자가 갑자기 장치를 시스템에서 제거했다는 사실을 운전자에게 알릴 수 있습니다. 커널 데이터 구조를 손상시키지 않고 현재 I/O 전송을 중단해야 할 뿐만 아니라 현재 사라진 장치에 대한 보류 중인 요청도 시스템과 해당 호출자로부터 정상적으로 제거되어야 합니다(나쁜 소식이 있는 경우). 또한 실수로 새 장치를 추가하면 커널이 리소스(예: 인터럽트 요청 라인)를 조작하고 드라이버에서 이전 장치를 제거하고 새 장치를 제자리에 놓을 수 있습니다.

드라이버는 시스템 호출을 할 수 없지만 나머지 커널과 상호 작용해야 하는 경우가 많습니다. 일반적으로 특정 커널 프로시저 호출이 허용됩니다. 예를 들어, 일반적으로 버퍼로 사용되는 하드 와이어드 메모리 페이지를 할당 및 할당 해제하기 위한 호출이 이루어지며, MMU, 타이머, DMA 컨트롤러, 인터럽트 컨트롤러 등을 관리하려면 기타 유용한 호출이 필요합니다.

  • 장치 독립적인 I/O 소프트웨어

일부 I/O 소프트웨어는 장치별로 다르지만 다른 부분은 장치 독립적입니다. 드라이버와 장치 독립적인 소프트웨어 사이의 정확한 경계는 시스템(및 장치)에 따라 다릅니다. 왜냐하면 장치 독립적으로 수행할 수 있는 일부 기능은 효율성이나 기타 이유로 실제로 드라이버에서 수행될 수 있기 때문입니다. 아래 표시된 기능은 일반적으로 장치 독립적인 소프트웨어에서 수행됩니다.

장치 드라이버를 위한 통합 인터페이스

버퍼

오류 보고서

특수 장치 할당 및 해제

장치 독립적인 블록 크기 제공

장치 독립형 소프트웨어의 기본 기능은 모든 장치에 공통적인 I/O 기능을 수행하고 사용자 수준 소프트웨어에 통일된 인터페이스를 제공하는 것입니다. 이제 위의 문제에 대해 더 자세히 논의하겠습니다.

먼저, 디바이스 드라이버의 통합 인터페이스에 대해 설명한다.

운영 체제의 주요 문제는 모든 I/O 장치와 드라이버를 거의 동일하게 보이게 만드는 방법입니다. 디스크, 프린터, 키보드 등이 모두 다르게 연결된 경우 새 장치가 나올 때마다 새 장치에 맞게 운영 체제를 수정해야 합니다. 새로운 장치가 나올 때마다 운영 체제를 해킹하는 것은 좋은 생각이 아닙니다.

이 문제의 한 측면은 장치 드라이버와 나머지 운영 체제 간의 인터페이스입니다. 아래 그림 (a)에서는 각 장치 드라이버가 서로 다른 운영 체제 인터페이스를 갖는 상황을 설명합니다. 즉, 시스템에서 호출할 수 있는 드라이버 기능이 드라이버마다 다르고 드라이버에 필요한 커널 기능도 드라이버마다 다를 수 있음을 의미합니다. 전체적으로 이것은 각각의 새로운 드라이버를 연결하려면 많은 새로운 프로그래밍 노력이 필요하다는 것을 의미합니다.

(a) 표준 드라이버 인터페이스는 없습니다. (b) 표준 드라이버 인터페이스가 있습니다.

대조적으로, 위의 (b)에서는 모든 드라이버가 동일한 인터페이스를 갖는 다른 디자인을 보여줍니다. 이제 드라이버 인터페이스를 준수하는 한 새 드라이버를 연결하는 것이 훨씬 쉬워졌습니다. 이는 드라이버 작성자가 드라이버에 대해 기대하는 바를 알고 있음을 의미합니다. 실제로 모든 장치가 정확히 동일하지는 않지만 일반적으로 장치 유형은 몇 가지 뿐이며 이들도 대개 거의 동일합니다.

작동 방식은 다음과 같습니다. 디스크나 프린터와 같은 각 장치 유형에 대해 운영 체제는 드라이버가 제공해야 하는 기능 집합을 정의합니다. 디스크의 경우 여기에는 자연스럽게 읽기 및 쓰기뿐만 아니라 전원 켜기 및 끄기, 포맷 및 기타 디스크 작업도 포함됩니다. 일반적으로 드라이버는 이러한 함수에 대한 포인터가 포함된 테이블을 유지 관리합니다. 드라이버가 로드되면 운영체제는 이 함수 포인터 테이블의 주소를 기록하므로, 함수 중 하나를 호출해야 할 경우 이 테이블을 통해 간접 호출이 가능하다. 이 함수 포인터 테이블은 드라이버와 나머지 운영 체제 간의 인터페이스를 정의합니다. 해당 클래스의 모든 장치(디스크, 프린터 등)는 이를 준수해야 합니다.

통합 인터페이스의 또 다른 측면은 I/O 장치의 이름을 지정하는 방법입니다. 장치 독립적인 소프트웨어는 기호 장치 이름을 올바른 드라이버에 매핑하는 역할을 합니다. 예를 들어, UNIX에서 장치 이름(예: /dev/disk0)은 특수 파일의 inode를 고유하게 지정하며 이 inode에는 해당 드라이버를 찾는 데 사용되는 주요 장치 번호가 포함됩니다. i-노드에는 읽거나 쓸 장치를 지정하기 위해 드라이버에 매개변수로 전달되는 보조 장치 번호도 포함되어 있습니다. 모든 장치에는 메이저 번호와 마이너 번호가 있으며, 메이저 번호를 사용하여 드라이버를 선택하면 모든 드라이버에 액세스할 수 있습니다.

명명과 밀접한 관련이 있는 것은 보호입니다. 시스템은 사용자가 액세스 권한이 없는 장치에 액세스하는 것을 어떻게 방지합니까? UNIX 및 Windows에서 장치는 파일 시스템에서 명명된 개체로 나타납니다. 이는 일반적인 파일 보호 규칙이 I/O 장치에도 적용된다는 의미입니다. 그런 다음 시스템 관리자는 각 장치에 대해 적절한 권한을 설정할 수 있습니다.

다음으로 버퍼링에 대해 설명합니다.

버퍼링은 여러 가지 이유로 블록 및 문자 장치에서도 문제가 됩니다. 이들 중 하나를 보려면 많은 사람들이 집에서 인터넷에 연결하기 위해 사용하는 모뎀(ADSL 비대칭 디지털 가입자 회선)에서 데이터를 읽는 프로세스를 생각해 보십시오. 들어오는 문자를 처리하는 한 가지 가능한 전략은 사용자 프로세스가 읽기 시스템 호출을 수행하고 문자를 기다리는 것을 차단하는 것입니다. 문자가 도착할 때마다 인터럽트가 발생하고, 인터럽트 서비스 루틴은 해당 문자를 사용자 프로세스에 전달하고 차단을 해제합니다. 문자를 어딘가에 배치한 후 프로세스는 다른 문자를 읽고 다시 차단합니다. 모델은 아래 그림 (a)에 나와 있습니다.

이러한 비즈니스 수행 방식의 문제점은 들어오는 각 문자에 대해 사용자 프로세스를 시작해야 한다는 것입니다. 짧은 시간 내에 프로세스를 여러 번 실행하는 것은 비효율적이므로 좋은 설계가 아닙니다.

개선 사항은 아래 그림 (b)에 나와 있습니다. 이 방법에서 사용자 프로세스는 사용자 공간에 n 문자 버퍼를 제공하고 n 문자를 읽습니다. 인터럽트 서비스 루틴은 들어오는 문자를 이 버퍼가 완전히 가득 찰 때까지 이 버퍼에 배치합니다. 그래야만 사용자 프로세스를 깨울 수 있습니다. 이 솔루션은 이전 솔루션보다 훨씬 효율적이지만 단점이 있습니다. 캐릭터가 도착할 때 버퍼가 페이지 아웃되면 어떻게 될까요? 버퍼는 메모리에 잠길 수 있지만 많은 프로세스가 임의로 메모리의 페이지 잠금을 시작하면 사용 가능한 페이지 풀이 줄어들고 성능이 저하됩니다.

(a) 버퍼링되지 않은 입력. (b) 사용자 공간의 버퍼링. (c) 커널에 버퍼링되어 사용자 공간에 복사됩니다. (d) 커널의 이중 버퍼링.

또 다른 접근 방식은 위의 (c)와 같이 커널 내에 버퍼를 생성하고 인터럽트 핸들러가 거기에 문자를 넣도록 하는 것입니다. 이 버퍼가 가득 차면 필요한 경우 사용자 버퍼가 있는 페이지가 삽입되고 버퍼는 한 번의 작업으로 거기에 복사됩니다. 이 솔루션은 훨씬 더 효율적입니다.

그러나 이 향상된 방식에도 문제가 있습니다. 사용자 버퍼가 있는 페이지를 디스크에서 가져올 때 도착하는 문자는 어떻게 되나요? 버퍼가 가득 차서 둘 곳이 없습니다. 해결책은 위의 (d)와 같이 첫 번째 버퍼가 채워진 후 비어지기 전에 두 번째 커널 버퍼를 사용하는 것입니다. 두 번째 버퍼가 가득 차면 사용자에게 복사될 수 있으며(사용자가 요청한 경우) 두 번째 버퍼가 사용자 공간에 복사되면 첫 번째 버퍼는 새 문자에 사용될 수 있습니다. 이러한 방식으로 두 개의 버퍼가 교대로 진행됩니다. 즉, 한 버퍼는 사용자 공간에 복사되고 다른 버퍼는 새로운 입력을 축적합니다. 이 솔루션을 이중 버퍼링이라고 합니다.

버퍼링의 또 다른 일반적인 형태는 메모리 영역과 두 개의 포인터로 구성된 순환 버퍼입니다. 하나는 새 데이터가 배치될 수 있는 다음 자유 단어를 가리키고 다른 하나는 아직 삭제되지 않은 버퍼의 첫 번째 데이터 단어를 가리킵니다. 많은 경우 하드웨어는 새 데이터가 추가될 때(예: 네트워크에서 막 도착할 때) 첫 번째 포인터를 앞으로 이동하고, 운영 체제는 데이터가 삭제 및 처리될 때 두 번째 포인터를 앞으로 이동하며, 두 포인터가 모두 순환하여 맨 위에 도달하면 맨 아래로 돌아갑니다.

버퍼링은 출력에도 중요합니다. 예를 들어, 사용자 프로세스가 쓰기 시스템 호출을 수행하여 n자를 출력하는 위의 (b) 모델을 사용하여 버퍼링 없이 모뎀에 출력하는 방법을 생각해 보세요. 이 시점에서 시스템에는 두 가지 옵션이 있습니다. 하나는 모든 문자를 쓸 때까지 사용자를 차단할 수 있지만 느린 전화 회선을 통해 완료하는 데 오랜 시간이 걸릴 수 있습니다. 둘째, 사용자가 더 많은 계산을 수행하는 동안 사용자를 즉시 ​​해제하고 I/O를 수행할 수도 있지만 이는 더 심각한 문제로 이어집니다. 사용자 프로세스는 출력이 완료되었음을 어떻게 알 수 있으며 버퍼를 재사용할 수 있습니까? 시스템은 신호나 소프트웨어 인터럽트를 생성할 수 있지만 이 프로그래밍 스타일은 구현하기 어렵고 경쟁 조건이 발생하기 쉽습니다. 더 나은 해결책은 위의 (c)와 유사하게 커널이 데이터를 커널 버퍼에 복사하고 호출자를 즉시 ​​차단 해제하는 것입니다. 이 모드의 실제 I/O가 언제 완료되는지는 중요하지 않습니다. 사용자는 버퍼를 차단 해제하자마자 버퍼를 재사용할 수 있습니다.

버퍼링은 널리 사용되는 기술이지만 단점도 있습니다. 데이터가 너무 많이 버퍼링되면 성능이 저하됩니다.

오류 보고는 다음에 설명됩니다.

오류는 다른 컨텍스트보다 I/O 컨텍스트에서 더 일반적입니다. 이러한 문제가 발생하면 운영 체제는 이를 최선을 다해 처리해야 합니다. 많은 오류는 장치마다 다르며 적절한 드라이버로 처리해야 하지만 오류 처리 프레임워크는 장치 독립적입니다.

I/O 오류의 한 가지 유형은 프로그래밍 오류입니다. 이러한 오류는 프로세스가 입력 장치(키보드, 스캐너, 마우스 등)에 쓰거나 출력 장치(프린터, 플로터 등)를 읽는 등 불가능한 작업을 요청할 때 발생합니다. 다른 오류에는 잘못된 버퍼 주소나 기타 매개변수 제공, 잘못된 장치 지정(예: 시스템에 디스크가 두 개만 있는 경우 디스크 3) 등이 포함됩니다. 이러한 오류를 처리하는 방법은 간단합니다. 호출자에게 오류 코드를 보고하기만 하면 됩니다.

또 다른 유형의 오류는 손상된 디스크 블록에 쓰려고 시도하거나 꺼진 카메라에서 읽으려고 시도하는 등의 실제 I/O 오류입니다. 이런 경우 어떻게 해야 할지 결정하는 것은 운전자의 몫입니다. 드라이버가 무엇을 해야할지 모르는 경우 장치 독립적인 소프트웨어에 문제를 다시 전달할 수 있습니다.

이 소프트웨어의 기능은 환경과 오류의 성격에 따라 달라집니다. 단순한 읽기 오류이고 대화형 사용자가 가능한 경우 사용자에게 무엇을 해야 할지 묻는 대화 상자가 표시될 수 있습니다. 옵션에는 특정 횟수만큼 재시도, 오류 무시, 호출 프로세스 종료 등이 포함될 수 있습니다. 사용할 수 있는 사용자가 없는 경우 실행 가능한 유일한 접근 방식은 오류 코드와 함께 시스템 호출이 실패하도록 하는 것입니다.

그러나 일부 오류는 이 방법으로 처리할 수 없습니다. 예를 들어 루트 디렉터리나 사용 가능한 차단 목록과 같은 중요한 데이터 구조가 손상되었을 수 있습니다. 이 경우 시스템은 오류 메시지를 표시하고 종료해야 할 수 있으며 수행할 수 있는 작업이 많지 않습니다.

다음으로 특수 디바이스 할당 및 해제에 대해 설명합니다.

프린터와 같은 일부 장치는 특정 순간에 단일 프로세스에서만 사용할 수 있습니다. 장치 사용 요청을 검사하고 요청된 장치를 사용할 수 있는지 여부에 따라 이를 수락하거나 거부하는 것은 운영 체제에 달려 있습니다. 이러한 요청을 처리하는 간단한 방법은 프로세스에 장치에서 특수 파일을 직접 열도록 요청하는 것입니다. 장치를 사용할 수 없으면 열기가 실패합니다. 이러한 전용 장치를 종료한 후 해제합니다.

또 다른 접근 방식은 특수 메커니즘을 사용하여 특수 장치를 요청하고 해제하는 것입니다. 사용할 수 없는 장치를 얻으려는 시도는 실패하는 대신 호출자를 차단합니다. 차단된 프로세스는 대기열에 추가됩니다. 조만간 요청된 장치를 사용할 수 있게 되며 대기열의 첫 번째 프로세스가 이를 가져와 실행을 계속할 수 있습니다.

디스크마다 섹터 크기가 다를 수 있습니다. 이 사실을 숨기고 상위 계층에 균일한 블록 크기를 제공하는 것은 장치 독립적인 소프트웨어에 달려 있습니다. 여러 섹터를 단일 논리 블록으로 처리합니다. 이러한 방식으로 상위 계층은 물리적 섹터 크기에 관계없이 모두 동일한 논리적 블록 크기를 사용하는 추상 장치만 처리합니다. 마찬가지로 일부 문자 장치는 한 번에 1바이트(예: 마우스)로 데이터를 전송하는 반면, 다른 문자 장치는 더 큰 단위(예: 이더넷 인터페이스)로 데이터를 전송합니다. 이러한 차이점은 숨겨져 있을 수도 있습니다.

18.11.4.4 사용자 공간 I/O 소프트웨어

대부분의 I/O 소프트웨어는 운영 체제 내에 있지만 그 중 일부는 사용자 프로그램과 연결된 라이브러리 또는 커널 외부에서 실행되는 전체 프로그램으로 구성됩니다. I/O 시스템 호출을 포함한 시스템 호출은 일반적으로 라이브러리 프로시저에 의해 이루어집니다. C 프로그램에 다음 호출이 포함된 경우:

cpp
count = write(fd, buffer, nbytes);

라이브러리 프로시저 쓰기는 프로그램과 연결될 수 있으며 런타임 메모리의 바이너리 프로그램에 포함될 수 있습니다. 다른 시스템에서는 프로그램 실행 중에 라이브러리를 로드할 수 있습니다. 그럼에도 불구하고 이러한 모든 라이브러리 프로시저의 모음은 분명히 I/O 시스템의 일부입니다.

이러한 프로시저는 시스템 호출을 위한 적절한 위치에 매개변수를 배치하는 것 외에 다른 I/O 프로시저가 실제로 실제 작업을 수행합니다. 특히 입력과 출력의 형식화는 라이브러리 프로시저에 의해 수행됩니다. C의 예로는 형식 문자열과 일부 변수를 입력으로 받아들이고 ASCII 문자열을 작성한 다음 write를 호출하여 문자열을 출력하는 printf가 있습니다. printf의 예로서 다음 명령문을 고려하십시오.

cpp
printf("The square of %3d is %6d\n", i, i*i);

14자 문자열 "the square of" 뒤에 i 값을 3자 문자열로 형식화하고, 4자 문자열 ""이 ""이고, i2가 6자이고, 마지막으로 개행 문자입니다.

모든 사용자 수준 I/O 소프트웨어가 라이브러리 루틴으로 구성되는 것은 아닙니다. 또 다른 중요한 범주는 다중 프로그래밍 시스템에서 특수 I/O 장치를 처리하는 방법인 스풀링 시스템입니다. 일반적인 스풀링 장치인 프린터를 생각해 보십시오. 모든 사용자 프로세스가 프린터의 문자 특수 파일을 열도록 하는 것은 기술적으로 쉽지만 한 프로세스가 해당 파일을 열고 몇 시간 동안 아무 작업도 하지 않는다고 가정하면 다른 프로세스는 아무 것도 인쇄할 수 없습니다.

아래 다이어그램은 I/O 시스템을 요약하여 모든 레이어와 각 레이어의 주요 기능을 보여줍니다. 맨 아래부터 시작하여 계층은 하드웨어, 인터럽트 처리기, 장치 드라이버, 장치 독립적인 소프트웨어, 마지막으로 사용자 프로세스입니다.

다이어그램의 화살표는 제어 흐름을 보여줍니다. 예를 들어, 사용자 프로그램이 파일에서 블록을 읽으려고 하면 운영 체제가 호출을 수행하도록 호출됩니다. 장치 독립적인 소프트웨어는 버퍼 캐시에서 이를 찾습니다. 필요한 블록이 없으면 장치 드라이버를 호출하여 디스크에서 블록을 가져오도록 하드웨어에 요청합니다. 그런 다음 디스크 작업이 완료되고 호출자의 버퍼에서 데이터를 안전하게 사용할 수 있을 때까지 프로세스가 차단됩니다.

디스크가 완성되면 하드웨어는 인터럽트를 생성합니다. 인터럽트 핸들러를 실행하는 목적은 무슨 일이 일어났는지, 즉 현재 어떤 장치에 주의가 필요한지 알아내는 것입니다. 그런 다음 장치에서 상태를 추출하고 휴면 프로세스를 깨워 I/O 요청을 완료하고 사용자 프로세스가 계속되도록 허용합니다.

18.11.5 윈도우 I/O

18.11.5.1 동기 및 동기 I/O

비동기식 및 동기식 IO의 비교 다이어그램은 다음과 같습니다.

dwFlagsAndAttributes 매개변수의 일부로 FILE_FLAG_OVERLAPPED를 지정하지 않고 CreateFile을 호출하면 파일 개체가 동기 I/O용으로만 생성됩니다. 이는 가장 간단한 작업이므로 동기 I/O를 먼저 처리하겠습니다. I/O를 수행하는 주요 함수는 ReadFile 및 WriteFile이며, 이는 모든 파일 객체에서 작동합니다(반드시 파일 시스템 파일을 가리킬 필요는 없음).

cpp
BOOL ReadFile(HANDLE hFile, LPVOID lpBuffer, DWORD nNumberOfBytesToRead, LPDWORD lpNumberOfBytesRead, LPOVERLAPPED lpOverlapped);
BOOL WriteFile(HANDLE hFile, LPCVOID lpBuffer, DWORD nNumberOfBytesToWrite, LPDWORD lpNumberOfBytesWritten, LPOVERLAPPED lpOverlapped);

Windows I/O 시스템은 본질적으로 비동기식입니다. 장치 드라이버가 자신이 제어하는 ​​하드웨어(예: 디스크 드라이브)에 요청하면 드라이버는 작업이 완료될 때까지 기다릴 필요가 없습니다. 대신 요청을 "보류 중"으로 표시하고 호출자에게 반환합니다. I/O가 진행되는 동안 스레드는 자유롭게 다른 작업을 수행할 수 있습니다. 잠시 후 하드웨어 장치가 I/O 작업을 완료합니다. 장치는 하드웨어 인터럽트를 발행하여 드라이버 제공 콜백이 실행되고 보류 중인 요청을 완료하도록 합니다.

동기 I/O를 사용하는 것은 간단하고 쉬우며 많은 상황에서 충분합니다. 그러나 많은 수의 요청을 처리하려는 경우 각 요청에 대해 스레드를 생성하여 I/O 작업을 시작하고 완료될 때까지 기다리는 것은 비효율적이며 확장성이 좋지 않습니다. 비동기 I/O는 CPU가 다른 코드를 실행하는 동안 I/O 작업이 동시에 실행되기 때문에 스레드가 하나의 요청을 시작한 후 다음 요청 등을 서비스하기 위해 반환되는 솔루션을 제공합니다. 이 단순화된 모델의 유일한 문제는 I/O 작업이 완료되었음을 스레드에 알리는 방법입니다.

비동기 작업 요청은 원래 CreateFile 호출(항상 동기)로 시작해야 하며, FILE_FLAG_OVERLAPPED 플래그는 dwFlagsAndAttributes 매개 변수의 일부로 지정되어야 하며 파일/장치는 비동기 모드에서 열립니다.

비동기 액세스를 위해 파일을 여는 결과 중 하나는 더 이상 파일 포인터가 없다는 것입니다. 즉, 모든 작업은 작업을 수행하기 위해 파일 시작 부분에서 오프셋을 어떻게든 제공해야 합니다(크기는 읽기/쓰기 호출의 일부이므로 문제가 되지 않습니다). 이는 구조가 겹치는 작업 중 하나이며 ReadFile 및 WriteFile에 마지막 매개변수로 전달되어야 합니다.

cpp
typedef struct _OVERLAPPED 
{
    ULONG_PTR Internal;
    ULONG_PTR InternalHigh;
    union 
    {
        struct 
        {
            DWORD Offset;
            DWORD OffsetHigh;
        };
           PVOID Pointer;
    };
    HANDLE hEvent;
} OVERLAPPED, *LPOVERLAPPED;

이 구조에는 세 가지 정보가 포함되어 있습니다.

  • Internal 및 Internal High는 I/O 관리자가 사용하는 이름이므로 기록해서는 안 됩니다.

  • offset 및 offset-high는 설정할 오프셋으로, 파일에서 작업이 시작되는 위치를 나타냅니다. 64비트 오프셋이 필요한 경우 Union의 포인터 멤버는 사용하기 더 쉬운 이러한 필드의 대안입니다.

  • hEvent는 null이 아닌 경우 작업이 완료될 때 I/O 관리자가 신호를 보내는 커널 이벤트 객체에 대한 핸들입니다.

또한 Windows는 수동으로 대기열에 있는 APC(Manually Queued APC)도 지원합니다.

18.11.5.2 I/O 완료 포트

I/O 완료 포트에는 비동기 I/O를 처리하는 데만 사용되는 것이 아니기 때문에 자체 주요 섹션이 있습니다. I/O 완료 포트는 파일 개체(여러 개가 있을 수 있음)와 연결되어 있으며 요청 대기열과 완료된 요청을 처리할 수 있는 스레드 목록을 캡슐화합니다. 비동기 작업이 완료될 때마다 완료 포트에서 대기 중인 스레드 중 하나가 깨어나 완료를 처리하고 다음 요청을 시작할 수도 있습니다. 예제 만들기:

cpp
const int Key = 1;
HANDLE hFile = ::CreateFile(..., FILE_FLAG_OVERLAPPED, ...);
HANDLE hOldCP = ::CreateIoCompletionPort(hFile, hNewCP, Key, 0);
assert(hOldCP == hNewCP);

위의 코드는 다른 파일 개체와 함께 반복될 수 있으며, 모두 완료 포트와 관련되어 있습니다. 아래 이미지는 완료 포트의 단순화된 다이어그램을 보여 주므로 어떤 스레드가 바인딩되어 있는지, 모든 작동 방식을 확인할 수 있습니다.

I/O 완료 포트의 목적은 작업자 스레드가 완료된 I/O 작업을 처리할 수 있도록 하는 것입니다. 여기서 "작업자 스레드"는 완료 포트에 바인딩된 모든 스레드를 참조할 수 있습니다.

18.11.5.3 장비 및 배관

장치(즉, 파일 시스템이 아닌 파일)를 사용하는 것은 본질적으로 파일 시스템 파일을 사용하는 것과 다르지 않습니다. ReadFile 및 WriteFile 함수는 비동기식을 포함한 모든 장치에서 작동하지만 모든 장치가 읽기 및 쓰기 작업을 지원하는 것은 아닙니다. 특히 장치의 경우 I/O 작업을 수행하는 또 다른 함수인 DeviceIoControl이 있습니다.

cpp
BOOL DeviceIoControl(HANDLE hDevice, DWORD dwIoControlCode, LPVOID lpInBuffer, DWORD nInBufferSize, LPVOID lpOutBuffer, DWORD nOutBufferSize, LPDWORD lpBytesReturned, LPOVERLAPPED lpOverlapped);

심볼릭 링크의 다른 용도는 "소프트웨어 드라이버"입니다. 즉, 하드웨어를 관리하지는 않지만 사용자 모드에서 수행할 수 없는 작업을 수행해야 하는 드라이버입니다. 일반적인 예로는 Process Explorer 자체(드라이버의 클라이언트)가 장치에 대한 핸들을 열고 장치에 대한 DeviceIoControl 호출을 수행할 수 있도록 기호 링크를 노출해야 하는 Process Explorer용 드라이버가 있으며, 드라이버에 의해 설정되고 Process Explorer에 알려진 통신 프로토콜에 따라 다양한 서비스를 요청할 수 있습니다.

파이프에는 익명과 이름이 지정된 두 가지 변형이 있습니다. 익명 파이프는 로컬 시스템으로 제한된 간단한 단방향 통신 메커니즘입니다. CreatePipe를 사용하여 익명 파이프 쌍을 만듭니다.

cpp
BOOL CreatePipe(PHANDLE hReadPipe, PHANDLE hWritePipe, LPSECURITY_ATTRIBUTES lpPipeAttributes, DWORD nSize);

CreatePipe는 파이프의 양쪽 끝에 대한 제어 핸들을 생성합니다. 익명 파이프를 사용하는 일반적인 예는 입력 및/또는 출력을 다른 프로세스로 리디렉션하여 한 프로세스가 다른 프로세스를 알거나 신경 쓰지 않고도 다른 프로세스에 데이터를 제공할 수 있도록 하는 것입니다. 입력/출력에 표준 핸들만 사용합니다.

아래 그림은 파이프라인 적용 사례이다. 익명 파이프를 생성하고 쓰기 끝을 EnumDevices 프로세스와 공유합니다. EnumDevices 프로세스에 의해 작성된 모든 내용은 파이프의 읽기 끝을 사용하여 읽을 수 있습니다. 이것이 작동하려면 파이프의 쓰기 및 쓰기가 EnumDevices 프로세스의 표준 출력에 연결되어야 하므로 표준 출력에 대한 모든 호출은 파이프를 통해 사용할 수 있습니다.

18.11.6 시계

시계(타이머라고도 함)는 다양한 이유로 다중 프로그래밍 시스템의 작동에 필수적입니다. 시간을 유지하고 하나의 프로세스가 CPU 등을 독점하는 것을 방지합니다. 시계 소프트웨어는 시계가 디스크와 같은 블록 장치도 아니고 마우스와 같은 문자 장치도 아닌 경우에도 장치 드라이버의 형태를 취할 수 있습니다.

컴퓨터에 일반적으로 사용되는 시계에는 두 가지 유형이 있는데, 둘 다 사람들이 사용하는 시계 및 시계와는 상당히 다릅니다. 더 간단한 클록은 110V 또는 220V 전력선에 연결되어 50Hz 또는 60Hz의 모든 전압 주기에서 인터럽트를 발생시킵니다. 이 종은 한때 지배적이었지만 지금은 희귀합니다.

또 다른 유형의 시계는 아래 그림과 같이 수정 발진기, 카운터 및 유지 레지스터의 세 가지 구성 요소로 구성됩니다. 석영 크리스털 조각을 절단하고 장력을 가하여 올바르게 장착하면 선택한 크리스털에 따라 일반적으로 수백 메가헤르츠에서 수 메가헤르츠 범위의 매우 정밀한 주기 신호를 생성할 수 있습니다. 전자 장치를 사용하면 이 기본 신호에 작은 정수를 곱하여 최대 몇 기가헤르츠 이상의 주파수를 얻을 수 있습니다. 일반적으로 모든 컴퓨터에는 컴퓨터의 다양한 회로에 동기화 신호를 제공하는 이러한 회로가 하나 이상 있습니다. 이 신호는 카운터에 입력되어 0으로 카운트다운됩니다. 카운터가 0에 도달하면 CPU 인터럽트가 발생합니다.

프로그래밍 가능한 시계.

프로그래밍 가능한 시계에는 일반적으로 여러 가지 작동 모드가 있습니다. 원샷 모드에서는 클록이 시작될 때 홀딩 레지스터의 값을 카운터에 복사한 다음 크리스털의 모든 펄스에서 카운터를 감소시킵니다. 카운터가 0에 도달하면 인터럽트가 발생하고 소프트웨어가 명시적으로 다시 시작될 때까지 중지됩니다. 구형파 모드에서는 제로화 및 인터럽트 발생 후 보유 레지스터가 자동으로 카운터에 복사되고 전체 프로세스가 무기한 반복됩니다. 이러한 주기적인 인터럽트를 클럭 신호라고 합니다.

프로그래밍 가능한 클록의 장점은 인터럽트 주파수를 소프트웨어로 제어할 수 있다는 것입니다. 500MHz 크리스털을 사용하는 경우 카운터는 2나노초마다 펄스를 발생시킵니다. (부호 없는) 32비트 레지스터를 사용하면 2나노초에서 8.6초 사이의 간격으로 인터럽트가 발생하도록 프로그래밍할 수 있습니다. 프로그래밍 가능 클록 칩에는 일반적으로 2개 또는 3개의 독립적인 프로그래밍 가능 클록이 포함되어 있으며 다른 많은 옵션(예: 다운 대신 카운트 업, 인터럽트 비활성화 등)이 있습니다.

컴퓨터의 전원이 꺼졌을 때 현재 시간을 잃지 않도록 대부분의 컴퓨터에는 디지털 시계에 사용되는 저전력 회로를 활용하는 배터리로 작동되는 백업 시계가 있습니다. 배터리 시계는 시작 시 읽을 수 있습니다. 백업 시계가 없으면 소프트웨어는 사용자에게 현재 날짜와 시간을 요청할 수 있습니다. 네트워크 시스템은 표준 방식으로 원격 호스트로부터 현재 시간을 얻을 수도 있습니다. 어떤 경우든 시간은 UNIX에서와 같이 1970년 1월 1일 오전 12시 UTC(협정 세계시, 이전 그리니치 표준시) 이후 또는 다른 기준 순간의 시계 틱 수로 변환됩니다. Windows의 시작점은 1980년 1월 1일입니다. 매 클록 주기마다 실시간이 카운트만큼 증가합니다. 일반적으로 시스템 시계와 백업 시계를 수동으로 설정하고 두 시계를 동기화하기 위한 유틸리티가 제공됩니다.

18.11.7 전원 관리

최초의 범용 전자컴퓨터인 에니악(ENIAC)은 18,000개의 진공관을 갖고 140,000와트의 전력을 소비했습니다. 결과적으로는 적지 않은 전기 요금이 추가됩니다. 트랜지스터가 발명된 이후 전력 사용량은 급격히 줄어들었고 컴퓨터 산업은 전력 수요에 대한 관심을 잃었습니다. 그러나 이제 여러 가지 이유로 전원 관리가 다시 주목을 받고 있으며 운영 체제가 여기서 큰 역할을 합니다.

데스크톱 컴퓨터부터 시작해 보겠습니다. 데스크탑 컴퓨터는 일반적으로 200와트의 전원 공급 장치를 갖추고 있습니다(일반적으로 85% 효율, 즉 입력 에너지의 15%가 난방을 위해 손실됨을 의미). 이러한 기계 1억 대가 전 세계에서 동시에 켜지면 총 20,000메가와트의 전력이 필요합니다. 이는 평균 크기의 원자력 발전소 20개를 합친 출력입니다. 전력수요를 절반으로 줄이면 원전 10기를 없앨 수 있다. 환경적 관점에서 볼 때, 10개의 원자력 발전소(또는 이에 상응하는 수의 화석 연료 발전소)를 제거하는 것은 큰 승리이며 추구할 가치가 있습니다.

전력이 큰 문제가 되는 또 다른 분야는 랩톱, 휴대용 장치, 웹패드 등 배터리로 구동되는 컴퓨터입니다. 문제의 핵심은 배터리가 최대 몇 시간 동안 지속될 만큼 충분한 충전량을 유지하지 못한다는 것입니다. 더욱이, 배터리 회사, 컴퓨터 회사, 가전제품 회사의 광범위한 연구에도 불구하고 진전은 더디었습니다. 18개월마다 성능이 두 배로 증가하는 데 익숙한 업계(무어의 법칙)에 있어 발전이 부족하다는 것은 물리 법칙을 거스르는 것처럼 보입니다. 따라서 컴퓨터가 에너지를 덜 사용하도록 만들어 기존 배터리의 수명을 연장하는 것이 모든 사람의 최우선 과제입니다. 여기서 운영 체제도 중요한 역할을 합니다.

가장 낮은 수준에서 하드웨어 공급업체는 전자 제품을 보다 에너지 효율적으로 만들기 위해 노력하고 있습니다. 사용되는 기술에는 트랜지스터 크기 감소, 동적 전압 스케일링 사용, 낮은 스윙 및 단열 버스 사용 및 유사한 기술이 포함됩니다. 에너지 소비를 줄이는 일반적인 방법에는 두 가지가 있습니다.

  • 첫 번째는 운영 체제가 컴퓨터의 특정 부분(주로 I/O 장치)을 사용하지 않을 때 종료하는 경우입니다. 종료된 장치는 에너지를 거의 또는 전혀 소비하지 않기 때문입니다.

  • 두 번째는 앱이 배터리 수명을 연장하기 위해 더 적은 에너지를 사용하므로 사용자 경험의 품질이 저하될 수 있다는 것입니다.

위의 방법들은 나중에 소개하겠지만 먼저 전원 공급 장치 사용과 관련된 하드웨어 설계를 소개하겠습니다.

18.11.7.1 하드웨어 문제

배터리에는 일회용 배터리와 충전식 배터리의 두 가지 유형이 있습니다. 일회용 배터리(가장 일반적으로 AAA, AA, D)는 휴대용 장치를 작동하는 데 사용할 수 있지만 크고 밝은 화면의 노트북에 전원을 공급하기에는 에너지가 충분하지 않습니다. 이와 대조적으로 충전식 배터리는 몇 시간 동안 노트북에 전력을 공급할 만큼 충분한 에너지를 저장할 수 있습니다. 이곳에서는 니켈-카드뮴 배터리가 지배적이었지만 니켈-금속 수소화물 배터리로 대체되었습니다. 이 배터리는 더 오래 지속되고 결국 폐기될 때 환경에 심각한 오염을 일으키지 않습니다. 리튬 이온 배터리는 훨씬 더 좋고 완전히 방전되지 않고도 재충전이 가능하지만 용량도 심각하게 제한됩니다.

배터리를 절약하기 위해 대부분의 컴퓨터 공급업체에서 취하는 일반적인 접근 방식은 CPU, 메모리 및 I/O 장치가 켜짐, 최대 절전 모드, 최대 절전 모드 및 꺼짐 등 여러 상태를 갖도록 설계하는 것입니다. 장치를 사용하려면 장치가 켜져 있어야 합니다. 장치가 짧은 시간 동안 필요하지 않은 경우 절전 모드로 전환하여 에너지 소비를 줄일 수 있습니다. 더 긴 시간 동안 필요하지 않을 것으로 예상되는 경우 최대 절전 모드로 전환하여 에너지 소비를 더욱 줄일 수 있습니다. 여기서의 단점은 장치를 최대 절전 모드에서 해제하는 것이 일반적으로 최대 절전 모드에서 해제하는 것보다 더 많은 시간과 노력이 필요하다는 것입니다. 마지막으로 장치가 꺼지면 아무 작업도 수행하지 않으며 전력도 소비하지 않습니다. 모든 장치에 이러한 상태가 모두 있는 것은 아니지만, 그럴 경우 적절한 경우 상태 전환을 관리하는 것은 운영 체제에 달려 있습니다.

일부 컴퓨터에는 2개 또는 3개의 전원 버튼이 있습니다. 그 중 하나는 전체 컴퓨터를 절전 모드로 전환할 수 있으며, 문자를 입력하거나 마우스를 움직여 신속하게 절전 모드를 해제할 수 있습니다. 또 다른 가능성은 컴퓨터를 최대 절전 모드로 전환하는 것입니다. 최대 절전 모드에서는 절전 모드를 해제하는 데 훨씬 더 오랜 시간이 걸립니다. 두 경우 모두 이러한 버튼은 일반적으로 운영 체제에 신호를 보내는 것 외에는 소프트웨어에서 나머지 작업을 수행합니다.

전원 관리는 운영 체제가 처리해야 하는 많은 문제를 야기합니다. 이들 중 다수는 리소스 최대 절전 모드, 선택적으로 장치를 일시적으로 끄거나 최소한 유휴 상태일 때 전력 소비를 줄이는 것과 관련이 있습니다. 답변해야 할 질문은 다음과 같습니다. 어떤 장치를 제어할 수 있습니까? 켜짐/꺼짐 상태인가요, 아니면 중간 상태인가요? 저전력 상태에서는 얼마나 많은 전력을 절약할 수 있나요? 기기를 다시 시작하면 에너지가 소모되나요? 저전력 상태로 들어갈 때 일부 컨텍스트를 저장해야 합니까? 전체 전력을 복원하는 데 얼마나 걸리나요? 물론 이러한 질문에 대한 대답은 장치마다 다르므로 운영 체제는 다양한 가능성을 처리할 수 있어야 합니다.

다양한 연구자들이 전력이 어디로 가는지 결정하기 위해 노트북을 연구했습니다. Li 등, Lorch 및 Smith(1998)는 랩톱 장치에서 측정을 수행하여 아래 그림과 같은 결과를 얻었습니다. Weiseret al. (1994)도 측정을 수행했지만 그 값을 발표하지는 않았습니다. 그들은 단순히 처음 세 가지 전력 소비 요소가 모니터, 하드 디스크 및 CPU라고 말했습니다. 측정된 컴퓨터 브랜드마다 에너지 요구량이 다르기 때문에 이러한 수치가 일관되지는 않지만 모니터, 하드 드라이브 및 CPU가 에너지 절약을 위한 확실한 목표임은 분명합니다. 스마트폰과 같은 장치에는 라디오나 GPS와 같이 전력을 많이 소비하는 다른 장치가 있을 수 있습니다.

장비 Li et al. (1994) 로치와 스미스(1998)

모니터 68% 39%

CPU 12% 18%

하드 드라이브 20% 12%

모뎀

  • 6%

소리

  • 2%

기억 0.5% 1%

기타

  • 22%

노트북 컴퓨터의 각 구성 요소의 전력 소비입니다.

18.11.7.2 운영 체제 문제

운영 체제는 에너지 관리에 핵심적인 역할을 하고 모든 장치를 제어하므로 무엇을 언제 종료할지 결정해야 합니다. 장치를 종료하고 장치가 곧 다시 필요한 경우 다시 시작할 때 짜증나는 지연이 발생할 수 있습니다. 반면, 장치를 끄는 데 너무 오래 기다리면 에너지가 불필요하게 낭비됩니다.

비결은 운영 체제가 언제 종료하고 언제 종료할지에 대해 올바른 결정을 내릴 수 있도록 하는 알고리즘과 경험적 방법을 찾는 것입니다. 문제는 '좋다'는 기준이 매우 주관적이라는 점이다. 사용자는 30초 동안 컴퓨터를 사용하지 않은 후 키 입력에 응답하는 데 2초가 걸린다는 것을 알 수 있습니다. 이는 허용 가능한 수준입니다. 동일한 조건에서 다른 사용자가 연속해서 깜박일 수 있습니다. 오디오 입력이 없으면 컴퓨터는 이러한 사용자를 구별할 수 없습니다.

  • 모니터

모니터는 에너지 예산의 가장 큰 돼지입니다. 선명하고 밝은 이미지를 얻으려면 화면에 백라이트가 있어야 하는데, 이는 많은 에너지를 필요로 합니다. 많은 운영 체제는 몇 분 동안 활동이 없으면 디스플레이를 꺼서 에너지를 절약하려고 합니다. 일반적으로 사용자는 종료 간격을 결정할 수 있으므로 화면을 자주 끄는 것과 배터리를 빠르게 소모하는 것(사용자에게 실제로 필요하지 않을 수 있음) 간의 균형을 따질 수 있습니다. 아무 키나 누르거나 포인팅 장치를 움직일 때 디스플레이가 거의 즉시(비디오 RAM에서) 재생성되므로 디스플레이를 끄는 것은 절전 상태입니다.

Flinn과 Satyanarayanan(2004)은 가능한 개선을 제안했습니다. 그들은 독립적으로 전원을 켜거나 끌 수 있는 구역으로 디스플레이를 구성할 것을 제안했습니다. 아래 이미지에서는 16개 영역이 점선으로 구분되어 있습니다. (a)와 같이 커서가 창 2에 있을 때 오른쪽 아래 모서리에 있는 4개 영역만 켜져야 합니다. 나머지 12개는 어두울 수 있어 화면 전력의 3/4를 절약할 수 있습니다.

사용자가 창 1로 커서를 이동하면 창 2의 영역이 어두워지고 창 1 뒤의 영역이 열릴 수 있습니다. 그러나 창 1은 9개 영역에 걸쳐 있으므로 더 많은 전력이 필요합니다. 창 관리자가 무슨 일이 일어나고 있는지 감지할 수 있으면 (b)에 표시된 대로 영역을 캡처하는 작업을 통해 창 1을 4개 영역으로 자동으로 이동할 수 있습니다. 9/16 최대 전력에서 4/16 최대 전력으로 감소하려면 창 관리자가 전력 관리를 이해하거나 다른 시스템의 명령을 받아들일 수 있어야 합니다. 더 정교한 기능은 완전히 채워지지 않은 창을 부분적으로 밝히는 기능입니다. 예를 들어 짧은 텍스트 줄이 포함된 창의 오른쪽은 어두운 상태로 남아 있을 수 있습니다.

영역 백라이트 디스플레이를 사용합니다. (a) 창 2를 선택하면 이동하지 않습니다. (b) 윈도우 1을 선택한 후, 조명되는 영역의 수를 줄이기 위해 이동합니다.

  • 디스크

하드 드라이브의 경우 채널이 없더라도 고속으로 계속 회전하려면 많은 에너지가 필요합니다. 많은 컴퓨터, 특히 랩탑은 일정 시간 동안 유휴 상태가 되면 디스크 속도가 느려집니다. 다음에 필요할 때 다시 회전합니다. 불행하게도 정지된 디스크는 절전 모드가 아닌 최대 절전 모드 상태이므로 다시 시작하는 데 몇 초가 걸리므로 사용자에게 눈에 띄는 지연이 발생합니다.

또한 디스크를 다시 시작하면 많은 에너지가 소비됩니다. 따라서 각 디스크에는 손익분기점인 특성 시간 Td이 있으며 일반적으로 5초에서 15초 사이입니다. 다음 디스크 액세스가 미래의 특정 시간 t에 도착할 것으로 예상된다고 가정합니다. t<Td인 경우 먼저 디스크 회전을 줄인 다음 빠르게 회전시키는 것보다 디스크 회전을 유지하는 데 더 적은 에너지가 필요합니다. t>Td이면 절약된 에너지로 인해 디스크를 먼저 회전시킨 다음 위로 회전시킬 가치가 있습니다. 좋은 예측이 이루어질 수 있다면(예: 과거 액세스 패턴을 기반으로) 운영 체제는 좋은 종료 예측을 하고 에너지를 절약할 수 있습니다. 실제로 대부분의 시스템은 보수적이며 몇 분 동안 활동이 없으면 디스크를 중지합니다.

디스크 에너지를 절약하는 또 다른 방법은 RAM에 큰 디스크 캐시를 두는 것입니다. 필요한 블록이 캐시에 있으면 읽기를 만족시키기 위해 유휴 디스크를 다시 시작할 필요가 없습니다. 마찬가지로, 디스크에 대한 쓰기가 캐시에 버퍼링될 수 있는 경우 쓰기를 처리하기 위해 중지된 디스크를 다시 시작할 필요가 없습니다. 캐시가 가득 차거나 읽기 누락이 발생할 때까지 디스크는 꺼진 상태로 남아 있을 수 있습니다.

불필요한 디스크 시작을 방지하는 또 다른 방법은 운영 체제에서 실행 중인 프로그램에 메시지나 신호를 보내 디스크 상태를 인식하도록 하는 것입니다. 일부 프로그램에는 건너뛰거나 지연할 수 있는 임의 쓰기가 있습니다. 예를 들어, 편집 중인 파일을 몇 분마다 디스크에 쓰도록 워드 프로세서를 설정할 수 있습니다. 이 시점에서 파일에 정상적으로 쓰는 경우 워드 프로세서는 디스크가 닫혀 있다는 것을 알고 디스크가 열릴 때까지 이 쓰기를 지연할 수 있습니다.

  • CPU

CPU를 관리하면 에너지도 절약할 수 있습니다. 노트북 CPU는 소프트웨어에서 최대 절전 모드로 전환되어 전력 소비를 거의 0으로 줄일 수 있습니다. 이 상태에서 할 수 있는 유일한 일은 인터럽트가 발생할 때 깨어나는 것뿐입니다. 따라서 CPU가 유휴 상태일 때마다 I/O를 기다리거나 수행할 작업이 없기 때문에 절전 모드로 전환됩니다.

많은 컴퓨터에는 CPU 전압, 클럭 주기, 전력 사용량 사이에 관계가 있습니다. 소프트웨어에서는 CPU 전압을 낮추어 에너지를 절약하고 클록 주기를 (대략 선형적으로) 단축할 수 있는 경우가 많습니다. 소비되는 전력은 전압의 제곱에 비례하기 때문에 전압을 절반으로 줄이면 CPU 속도는 절반으로 줄어들지만 전력은 1/4에 불과합니다.

이 속성은 40밀리초마다 프레임을 압축 해제하고 표시해야 하지만 속도가 빨라지면 유휴 상태가 되는 멀티미디어 뷰어와 같이 명시적인 기한이 있는 프로그램에서 사용할 수 있습니다. CPU가 40밀리초 동안 최고 속도로 실행될 때 x줄을 사용하고 절반 속도로 실행될 때 x/4줄을 사용한다고 가정해 보겠습니다. 멀티미디어 뷰어가 20밀리초 안에 프레임을 압축 해제하고 표시할 수 있다면 운영 체제는 20밀리초 동안 최대 전력으로 실행된 다음 20밀리초 동안 종료될 수 있으므로 총 에너지 소비량은 x/2줄입니다. 또는 마감일을 맞추기 위해 절반 전력으로 실행될 수 있지만 x/4줄만 사용합니다. 아래 그래프는 일정 시간 동안 최대 속도 및 최대 전력으로 달리는 것과 두 배 더 긴 시간 동안 절반 속도 및 1/4 전력으로 달리는 것을 비교한 것입니다. 두 경우 모두 동일한 작업이 수행되지만 (b)에서는 에너지의 절반만 소비됩니다.

(a) 최고 속도로 달리세요. (b) 전압은 2배, 클럭 속도는 2배, 전력 소모는 4배 감소합니다.

마찬가지로, 사용자가 초당 1개의 문자를 입력하지만 문자를 처리하는 데 필요한 작업이 100밀리초가 걸리는 경우 운영 체제는 긴 유휴 기간을 더 잘 감지하고 CPU 속도를 10배만큼 느리게 합니다. 즉, 천천히 실행하는 것이 빠르게 실행하는 것보다 에너지 효율적입니다**.

흥미롭게도 CPU 코어 수가 줄어든다고 해서 항상 성능 저하를 의미하는 것은 아닙니다. Hrubyet al. (2013)은 코어 속도가 감소함에 따라 네트워크 스택 성능이 향상되는 경우가 있음을 보여주었습니다. 그 설명은 코어가 그 자체의 이익을 위해 너무 빠를 수 있다는 것입니다. 예를 들어, 여러 개의 빠른 코어가 있는 CPU를 상상해 보십시오. 그 중 하나는 다른 코어에서 실행되는 생산자를 대신하여 네트워크 패킷 전송을 담당합니다. 생산자와 네트워크 스택은 공유 메모리를 통해 직접 통신하며 둘 다 전용 코어에서 실행됩니다. 생산자는 상당한 양의 계산을 수행하므로 네트워크 스택의 핵심을 따라잡을 수 없습니다. 일반적인 작업에서 네트워크는 전송해야 하는 모든 것을 전송하고, 실제로 전송할 데이터가 더 이상 없는지 확인하기 위해 특정 시간 동안 공유 메모리를 폴링합니다. 지속적인 폴링은 전력 소모에 매우 좋지 않기 때문에 결국 포기하고 휴면 상태가 됩니다. 얼마 지나지 않아 생산자는 더 많은 데이터를 제공하지만 이제 네트워크 스택은 빠른 휴면 상태에 있습니다. 스택을 깨우려면 시간이 걸리고 처리량이 줄어듭니다. 한 가지 가능한 해결책은 절대 최대 절전 모드를 사용하지 않는 것입니다. 하지만 그렇게 하면 전력 소비가 늘어나 우리가 달성하려는 목표와 정반대이므로 매력적이지 않습니다. 더 매력적인 솔루션은 느린 코어에서 네트워크 스택을 실행하여 항상 사용 중(따라서 절전 모드가 아님)을 유지하면서 전력 소비를 줄이는 것입니다. 네트워크 코어의 속도를 늦추도록 주의를 기울이면 모든 코어가 엄청나게 빠른 구성보다 성능이 향상됩니다.

  • 기억

메모리에는 두 가지 절전 옵션이 있습니다. 먼저 캐시를 비운 다음 캐시를 끌 수 있습니다. 정보 손실 없이 항상 주 메모리에서 다시 로드할 수 있습니다. 다시 로드는 동적으로 신속하게 수행될 수 있으므로 캐시를 끄면 절전 모드로 전환됩니다.

더 과감한 옵션은 주 메모리의 내용을 디스크에 쓴 다음 주 메모리 자체를 종료하는 것입니다. 이 접근 방식은 특히 디스크가 꺼져 있는 경우 많은 다시 로드 시간을 차지하면서 거의 모든 전원이 차단될 수 있기 때문에 최대 절전 모드입니다. 메모리가 끊어지면 CPU도 함께 끊어지거나 ROM에서 실행되어야 합니다. CPU가 차단되면 CPU를 깨우는 인터럽트로 인해 CPU가 사용 전에 메모리를 다시 로드할 수 있도록 ROM의 코드로 점프해야 합니다. 이러한 모든 오버헤드에도 불구하고 디스크에서 OS를 재부팅하는 것(보통 1분 이상 소요)보다 몇 초 안에 재부팅하는 것이 더 나은 경우 오랫동안(예: 몇 시간) 메모리를 종료하는 것이 가치가 있을 수 있습니다.

  • 무선 통신

점점 더 많은 수의 휴대용 컴퓨터가 외부 세계(예: 인터넷)에 무선으로 연결할 수 있습니다. 필요한 무선 송신기 및 수신기는 일반적으로 1급 전원 소켓입니다. 특히 수신 이메일을 듣기 위해 라디오 수신기를 항상 켜두면 배터리가 빨리 소모될 수 있습니다. 반면, 1분 동안 유휴 상태가 된 후 라디오가 꺼지면 수신 메시지를 놓칠 수 있으며 이는 분명히 바람직하지 않습니다.

Kravets와 Krishnan(1998)은 모바일 컴퓨터가 대용량 메모리와 디스크를 갖추고 전력 제약이 없는 고정 기지국과 통신한다는 사실을 활용하는 효율적인 솔루션을 제안했습니다. 그들은 모바일 컴퓨터가 라디오를 끄려고 할 때 기지국에 메시지를 보내도록 하고, 그 시점부터 기지국은 들어오는 메시지를 디스크에 버퍼링하도록 제안합니다. 모바일 컴퓨터는 잠자기 계획 기간을 명시적으로 표시하거나 라디오를 다시 켤 때 기지국에 간단히 알릴 수 있습니다. 이 시점에서 누적된 모든 메시지를 보낼 수 있습니다.

라디오가 꺼졌을 때 생성된 발신 메시지는 모바일 컴퓨터에 버퍼링됩니다. 버퍼가 가득 차면 라디오가 켜지고 대기열이 기지국으로 전송됩니다. 라디오는 언제 꺼야 할까요? 한 가지 가능성은 사용자나 애플리케이션이 결정하도록 하는 것입니다. 또 다른 방법은 몇 초 동안 유휴 상태가 된 후 끄는 것입니다. 언제 다시 켜야 합니까? 다시 말하지만, 사용자나 프로그램이 결정할 수 있으며 정기적으로 열어 인바운드 트래픽을 검사하고 대기 중인 메시지를 전송할 수 있습니다. 물론 출력 버퍼가 거의 가득 찼을 때도 켜져야 합니다. 다양한 다른 경험적 방법도 가능합니다.

이 전원 관리 체계를 지원하는 무선 기술의 예는 802.11("WiFi") 네트워크에서 찾을 수 있습니다. 802.11에서 모바일 컴퓨터는 액세스 포인트에 절전 모드가 될 것임을 알릴 수 있지만 기지국이 다음 비콘 프레임을 보내기 전에 깨어납니다. 액세스 포인트는 이러한 프레임을 주기적으로 전송하며, 이때 액세스 포인트는 처리할 데이터가 있음을 모바일 컴퓨터에 알릴 수 있습니다. 그러한 데이터가 없으면 모바일 컴퓨터는 다음 비콘 프레임까지 다시 잠자기 상태가 될 수 있습니다.

  • 열 관리

약간 다르지만 여전히 에너지 관련 문제는 열 관리입니다. 최신 CPU는 빠른 속도로 인해 극도로 뜨거워지며 데스크탑에는 케이스 밖으로 뜨거운 공기를 내보내는 내부 선풍기가 있는 경우가 많습니다. 전력 소비를 줄이는 것은 일반적으로 데스크탑의 드라이버 문제가 아니기 때문에 팬이 항상 켜져 있는 경우가 많습니다.

노트북의 경우에는 상황이 다릅니다. 운영 체제는 지속적으로 온도를 모니터링해야 하며 온도가 최대 허용 온도에 도달하는 시기를 선택할 수 있습니다. 팬을 켜서 소음이 발생하고 전력을 소비할 수 있습니다. 또는 화면 백라이트를 줄이고, CPU 속도를 낮추고, 디스크 회전 속도를 더욱 적극적으로 늦추는 등의 방식으로 전력 소비를 줄일 수 있습니다.

사용자의 일부 입력은 가치 있고 지침 역할을 할 수 있습니다. 예를 들어, 사용자는 팬 소음이 불쾌할 수 있음을 미리 지정하여 운영 체제가 대신 전력 소비를 줄이도록 할 수 있습니다.

  • 전원 관리

과거에는 배터리가 완전히 방전된 후 정지될 때까지 단순히 전류를 공급했으며 다시는 전류를 공급하지 않았습니다. 이제 모바일 장치는 운영 체제와 통신할 수 있는 스마트 배터리를 사용합니다. 운영 체제의 요청에 따라 최대 전압, 전류 전압, 최대 충전, 전류 충전, 최대 누설 전류율, 전류 누설 전류율 등과 같은 정보를 보고할 수 있습니다. 대부분의 모바일 장치에는 이러한 모든 매개변수를 쿼리하고 표시하도록 실행할 수 있는 프로그램이 있으며, 운영 체제의 제어에 따라 스마트 배터리에 다양한 작동 매개변수를 변경하도록 지시할 수도 있습니다.

일부 노트북에는 여러 개의 배터리가 있습니다. 운영 체제는 배터리가 곧 방전됨을 감지하면 전환 중에 결함을 일으키지 않고 다음 배터리로 원활하게 전환해야 합니다. 마지막 배터리가 거의 방전되면 운영 체제는 사용자에게 경고한 다음 파일 시스템이 손상되지 않았는지 확인하는 등의 순서로 종료합니다.

  • 장치 인터페이스

일부 운영 체제에는 ACPI(고급 구성 및 전원 인터페이스, 고급 구성 및 전원 인터페이스)라는 정교한 전원 관리 메커니즘이 있습니다. 운영 체제는 장치의 기능과 현재 상태를 보고하도록 요청하는 일관된 드라이버 명령을 보낼 수 있습니다. 이 기능은 플러그 앤 플레이와 결합할 때 특히 중요합니다. 부팅 후 운영 체제는 에너지 소비나 전원 관리 속성은 물론이고 어떤 장치가 있는지조차 알지 못하기 때문입니다.

또한 운전자에게 전력 수준을 줄이도록 지시하는 명령을 보낼 수도 있습니다(물론 이전에 학습한 기능에 따라 다름). 또한 전송되는 신호도 있는데, 특히 키보드나 마우스와 같은 장치가 일정 기간 동안 비활성 상태가 된 후 활동을 감지하는 경우 이는 시스템이 (거의) 정상 작동으로 돌아가는 신호입니다.

18.11.7.3 애플리케이션 문제

지금까지 운영 체제가 다양한 장치의 에너지 소비를 어떻게 줄일 수 있는지 살펴보았습니다. 하지만 또 다른 방법이 있습니다. 사용자 경험이 더 나쁠 때에도 프로그램에 에너지를 덜 사용하도록 지시하는 것입니다(배터리가 방전되고 조명이 꺼졌을 때 나쁜 경험은 경험하지 않는 것보다 낫습니다). 일반적으로 이 정보는 배터리 충전량이 특정 임계값 아래로 떨어지면 전달됩니다. 그런 다음 배터리 수명을 연장하기 위해 성능을 줄일지, 아니면 성능을 유지하고 에너지 부족 위험을 감수할지 여부를 결정하는 것은 프로그램에 달려 있습니다.

여기서 발생하는 한 가지 질문은 프로그램이 어떻게 성능을 줄여 에너지를 절약할 수 있느냐는 것입니다. 이 문제는 Flinn과 Satyanarayanan(2004)에 의해 연구되었으며 성능 감소가 어떻게 에너지를 절약할 수 있는지에 대한 네 가지 예를 제시했습니다. 본 연구에서는 정보가 다양한 형태로 사용자에게 제시된다. 성능 저하가 없을 때 최상의 정보가 제공됩니다. 성능 저하가 발생하면 사용자에게 제공되는 정보의 충실도(정확성)가 그렇지 않은 경우보다 떨어집니다.

에너지 사용량을 측정하기 위해 Flinn과 Satyanarayanan은 PowerScope라는 소프트웨어 도구를 설계했으며 이 도구의 기능은 프로그램의 전력 사용량 프로필을 제공하는 것입니다. 이를 사용하려면 소프트웨어로 제어되는 디지털 멀티미터를 통해 컴퓨터를 외부 전원에 연결해야 합니다. 소프트웨어는 멀티미터를 사용하여 입력 전원의 밀리암페어를 판독하여 컴퓨터가 소비하는 순간 전력을 결정할 수 있습니다. PowerScope가 수행하는 작업은 프로그램 카운터와 전력 사용량을 주기적으로 샘플링하고 이 데이터를 파일에 기록하는 것입니다. 프로그램이 종료된 후 파일을 분석하여 각 프로세스의 에너지 사용량을 제공하고 이러한 측정값은 관찰의 기초를 형성합니다. 하드웨어 절전 조치도 구현되고 성능 저하를 측정하는 기준선이 형성됩니다.

측정된 첫 번째 프로그램은 비디오 플레이어였습니다. 등급 없음 모드에서는 전체 해상도 및 컬러로 30fps로 재생됩니다. 품질 저하의 한 가지 형태는 색상 정보를 삭제하고 비디오를 흑백으로 표시하는 것입니다. 품질 저하의 또 다른 형태는 프레임 속도를 낮추는 것입니다. 이로 인해 깜박임이 발생하고 영화 품질이 크게 저하됩니다. 성능 저하의 또 다른 형태는 공간 해상도를 줄이거나 디스플레이 이미지를 축소하여 양방향의 픽셀 수를 줄이는 것입니다. 이 방법을 사용하면 약 30%의 에너지가 절약됩니다.

두 번째 프로그램은 음성 인식기입니다. 마이크를 샘플링하여 랩톱에서 분석할 수 있는 파형을 구성하거나 분석을 위해 무선 링크를 통해 고정 컴퓨터로 보낼 수 있습니다. 이렇게 하면 CPU 에너지가 절약되지만 무선 에너지가 소비됩니다. 다운그레이드는 더 작은 캐릭터와 더 단순한 음향 모델을 사용하여 수행되며 약 35%가 절약됩니다.

다음 예는 라디오 링크를 통해 지도를 얻는 지도 뷰어입니다. 다운그레이드에는 지도를 더 작은 크기로 자르거나 원격 서버에 더 작은 도로를 무시하도록 지시하여 전송되는 비트 수를 줄이는 것이 포함됩니다. 여기서도 약 35%의 이득이 달성되었습니다.

네 번째 실험은 JPEG 이미지를 웹 브라우저로 전송하는 것입니다. JPEG 표준에서는 이미지 품질과 파일 크기를 비교하는 다양한 알고리즘을 사용할 수 있으며 평균 이득은 9%에 불과합니다. 요약하면, 실험에 따르면 품질 저하를 어느 정도 수용하면 사용자가 특정 배터리로 더 오래 사용할 수 있는 것으로 나타났습니다.

18.12 다중 프로세서 시스템

전자(또는 광학) 구성 요소 간의 모든 통신은 궁극적으로 시간 척도, 거리 척도 및 관련된 논리적 구성이 서로 다른 잘 정의된 비트 문자열을 전송하는 것으로 귀결됩니다. 한 극단에는 2~1000개의 CPU가 공유 메모리를 통해 통신하는 공유 메모리 다중 프로세서가 있습니다. 이 모델에서 각 CPU는 전체 물리적 메모리에 동일하게 액세스할 수 있으며 LOAD 및 STORE 명령어를 사용하여 개별 단어를 읽고 쓸 수 있습니다. 저장 단어에 액세스하는 데는 일반적으로 1~10나노초가 걸립니다. 앞으로 살펴보겠지만 이제는 단일 CPU 칩에 여러 처리 코어를 배치하는 것이 일반적이며, 이는 주 메모리(때로는 캐시까지)에 대한 액세스를 공유합니다. 즉, 공유 메모리 다중 컴퓨터 모델은 물리적으로 분리된 CPU, 단일 CPU의 다중 코어 또는 이 둘의 조합을 사용하여 구현될 수 있습니다. 아래 그림 (a)에 표시된 모델은 간단해 보이지만, 그렇지 않으며 이면에서 많은 정보를 전달해야 하는 경우가 많습니다.

(a) 공유 메모리가 있는 다중 프로세서. (b) 메시지 전달을 통한 여러 컴퓨터. (c) 광역 분산 시스템.

다음은 CPU 메모리 쌍이 고속 상호 연결로 연결된 위 그림 (b)의 시스템입니다. 이 시스템을 메시지 전달 다중 컴퓨터라고 합니다. 각 메모리는 단일 CPU에 로컬이며 해당 CPU에서만 액세스할 수 있습니다. CPU는 상호 연결을 통해 여러 단어로 구성된 메시지를 보내 통신합니다. 상호 연결이 양호하면 짧은 메시지를 10~50마이크로초 안에 보낼 수 있는데, 이는 여전히 그림 8-1(a)의 메모리 액세스 시간보다 훨씬 깁니다. 이 디자인에는 공유 글로벌 메모리가 없습니다. 다중 컴퓨터(예: 메시지 전달 시스템)는 (공유 메모리) 다중 프로세서보다 구축하기 쉽지만 프로그래밍하기는 더 어렵습니다. 따라서 각 장르에는 팬이 있습니다.

세 번째 모델은 위의 (c)에 표시된 대로 인터넷과 같은 광역 네트워크를 통해 완전한 컴퓨터 시스템을 연결하여 분산 시스템을 형성합니다. 각각은 자체 메모리를 갖고 있으며 시스템은 메시지 전달을 통해 통신합니다. (b)와 (c)의 유일한 실제 차이점은 후자의 경우 전체 컴퓨터가 사용되며 메시지 시간은 일반적으로 10-100ms라는 것입니다. 이러한 긴 지연으로 인해 이러한 느슨하게 결합된 시스템은 (b)의 긴밀하게 결합된 시스템과 다르게 사용됩니다. 세 가지 유형의 시스템은 대기 시간이 약 3배 정도 차이가 나며, 이는 1일에서 3년의 차이입니다.

공유 메모리 멀티프로세서(또는 이하에서는 간단히 멀티프로세서)는 두 개 이상의 CPU가 공통 RAM에 대한 전체 액세스를 공유하는 컴퓨터 시스템입니다. 모든 CPU에서 실행되는 프로그램은 일반적인(일반적으로 페이징된) 가상 주소 공간을 봅니다. 이 시스템의 유일한 특이한 특징은 CPU가 메모리 단어에 일부 값을 쓴 다음 해당 단어를 다시 읽고 다른 값을 얻을 수 있다는 것입니다(다른 CPU가 값을 변경했기 때문에). 올바르게 구성되면 이 속성은 프로세서 간 통신의 기초를 형성합니다. 한 CPU는 일부 데이터를 메모리에 쓰고 다른 CPU는 이를 읽습니다.

대부분의 경우 다중 프로세서 운영 체제는 시스템 호출을 처리하고, 메모리 관리를 수행하고, 파일 시스템을 제공하고, I/O 장치를 관리하는 일반적인 운영 체제입니다. 그러나 프로세스 동기화, 리소스 관리, 예약 등 일부 영역에서는 고유한 기능을 가지고 있습니다.

18.12.1 다중 프로세서 하드웨어

모든 멀티프로세서는 각 CPU가 모든 메모리를 처리할 수 있다는 특성을 가지고 있지만 일부는 각 메모리 단어를 다른 메모리 단어만큼 빠르게 읽을 수 있다는 추가 특성을 가지고 있습니다. 이러한 시스템을 UMA(Uniform Memory Access, 통합 메모리 액세스) 다중 프로세서라고 합니다. 대조적으로, NUMA(Non-Uniform Memory Access) 다중 프로세서에는 이 속성이 없습니다.

18.12.1.1 버스 아키텍처 기반 UMA 다중 프로세서

가장 간단한 멀티프로세서는 아래 그림 (a)에 표시된 것처럼 단일 버스를 기반으로 합니다. 두 개 이상의 CPU와 하나 이상의 메모리 모듈은 모두 동일한 버스를 사용하여 통신합니다. CPU가 메모리 한 단어를 읽으려면 먼저 버스가 사용 중인지 확인합니다. 버스가 비어 있으면 CPU는 원하는 단어의 주소를 버스에 넣고 일부 제어 신호를 확인한 다음 메모리가 원하는 단어를 버스에 넣을 때까지 기다립니다.

CPU가 메모리를 읽거나 쓰려고 할 때 버스가 사용 중이면 CPU는 버스가 자유로워질 때까지 기다리게 되는데, 이것이 바로 문제입니다. 2개 또는 3개의 CPU를 사용하면 버스 경합을 관리할 수 있지만 32개 또는 64개를 사용하면 견딜 수 없습니다. 시스템은 버스 대역폭에 의해 완전히 제한되며 대부분의 CPU는 대부분의 시간 동안 유휴 상태입니다.

버스 기반 멀티프로세서 3개. (a) 캐싱이 없습니다. (b) 캐싱을 사용하십시오. (c) 캐시와 개인 메모리가 있습니다.

해결 방법은 위의 (b)와 같이 각 CPU에 캐시를 추가하는 것입니다. 캐시는 CPU 칩 내부, CPU 칩 옆, 프로세서 보드 또는 이 세 가지의 조합에 위치할 수 있습니다. 이제 로컬 캐시에서 많은 읽기를 수행할 수 있으므로 버스 트래픽이 크게 줄어들고 시스템은 더 많은 CPU를 지원할 수 있습니다. 일반적으로 캐싱은 개별 단어를 기반으로 하지 않고 32 또는 64바이트 블록을 기반으로 합니다. 단어가 참조되면 해당 단어의 전체 블록(캐시 라인이라고 함)이 해당 단어와 접촉하는 CPU의 캐시로 가져옵니다.

각 캐시 블록은 읽기 전용(동시에 여러 캐시에 존재할 수 있음) 또는 읽기-쓰기(다른 캐시에 존재하지 않을 수 있음)로 표시됩니다. CPU가 하나 이상의 원격 캐시에 단어를 쓰려고 시도하면 버스 하드웨어는 쓰기를 감지하고 버스에 신호를 보내 다른 모든 캐시에 쓰기를 알립니다. 다른 캐시에 메모리에 있는 것과 똑같은 복사본인 "깨끗한" 복사본이 있는 경우 해당 복사본을 삭제하고 작성자가 이를 수정하기 전에 메모리에서 캐시 블록을 가져오도록 할 수 있습니다. 다른 캐시에 "잘못된"(즉, 수정된) 복사본이 있는 경우 쓰기를 계속하기 전에 해당 복사본을 메모리에 다시 쓰거나 버스를 통해 작성자에게 직접 전송해야 합니다. 이 규칙 집합을 캐시 일관성 프로토콜이라고 하며 많은 규칙 중 하나입니다.

또 다른 가능성은 위 그림 (c)의 설계로, 각 CPU에는 캐시뿐 아니라 전용(개인) 버스를 통해 액세스되는 로컬 개인 메모리도 있습니다. 이 구성을 최대한 활용하려면 컴파일러는 모든 프로그램 텍스트, 문자열, 상수, 기타 읽기 전용 데이터, 스택 및 지역 변수를 전용 메모리에 배치해야 합니다. 그러면 공유 메모리는 쓰기 가능한 공유 변수에만 사용됩니다. 대부분의 경우 이러한 신중한 레이아웃은 버스 트래픽을 크게 감소시키지만 컴파일러의 적극적인 협력이 필요합니다.

18.12.1.2 크로스바 스위치를 사용하는 UMA 다중 프로세서

최상의 캐시를 사용하더라도 단일 버스를 사용하면 UMA 다중 프로세서의 크기가 약 16개 또는 32개의 CPU로 제한됩니다. 그 외에도 다른 유형의 상호 연결 네트워크가 필요합니다. n개의 CPU를 k개의 메모리에 연결하는 가장 간단한 회로는 아래 그림과 같이 크로스바 스위치입니다. 크로스바 스위치는 임의의 방식으로 일련의 입력 라인을 일련의 출력 라인에 연결하기 위해 수십 년 동안 전화 전환 스위치에 사용되었습니다.

수평선(입력)선과 수직(출력)선의 각 교차점에는 교차점이 있는데, 이는 수평선과 수직선의 연결 여부에 따라 전기적으로 열리거나 닫힐 수 있는 작은 전자 스위치입니다. 아래 그림 (a)에서는 세 개의 교차점이 동시에 닫혀 있어 (CPU, 메모리) 쌍 (010000), (101101) 및 (110010)이 동시에 연결될 수 있을 뿐만 아니라 다른 많은 조합도 가능합니다. 실제로 조합의 수는 8방향을 보드에 안전하게 배치할 수 있는 다양한 방법의 수와 같습니다.

(a) 8×8 크로스바 스위치. (b) 열린 교차로. (c) 닫힌 교차로.

크로스바의 가장 좋은 기능 중 하나는 비차단 네트워크라는 것입니다. 즉, 일부 크로스포인트나 라인이 이미 점유되어 있기 때문에(메모리 모듈 자체를 사용할 수 있다고 가정할 때) CPU 연결이 거부되지 않으며 모든 상호 연결에 이러한 좋은 속성이 있는 것은 아닙니다. 또한 사전 계획이 필요하지 않으며 임의의 7개 연결을 설정하더라도 나머지 CPU를 나머지 메모리에 항상 연결할 수 있습니다.

물론, 두 개의 CPU가 동시에 동일한 모듈에 액세스하려는 경우 메모리 경합이 여전히 존재합니다. 그러나 메모리를 n개의 셀로 나누면 위 모델에 비해 경합이 n배로 줄어듭니다.

크로스바 스위치의 가장 나쁜 특성 중 하나는 n^2에 따라 교차점 수가 증가한다는 것입니다. 예를 들어, 1000개의 CPU와 1000개의 메모리가 있는 시스템에는 100만 개의 교차점이 필요합니다. 이러한 대형 크로스바 스위치는 실현 가능하지 않습니다. 그러나 중간 규모 시스템의 경우 크로스오버 설계가 가능합니다.

18.12.1.3 다중 레벨 스위칭 네트워크를 사용하는 UMA 다중 프로세서

완전히 다른 멀티프로세서 설계는 아래 그림(a)에 표시된 일반 2×2를 기반으로 합니다. 이 스위치에는 2개의 입력과 2개의 출력이 있으며, 입력 라인에 도착하는 메시지는 출력 라인으로 전환될 수 있습니다. 우리의 목적에 따라 메시지는 아래 그림 (b)에 표시된 것처럼 최대 4개 부분으로 구성됩니다. 모듈 필드는 사용할 메모리를 나타내고, 주소는 모듈 내의 주소를 지정하며, opcode는 READ 또는 WRITE와 같은 작업을 제공합니다. 마지막으로 선택적 값 필드에는 WRITE에 쓸 32비트 단어와 같은 피연산자가 포함될 수 있습니다. 스위치는 모듈 필드를 확인하고 해당 필드를 사용하여 메시지가 X 또는 Y로 전송되어야 하는지 결정합니다.

(a) 두 개의 입력 라인 a와 B와 두 개의 출력 라인 X와 Y가 있는 2×2 스위치. (B) 메시지 형식.

18.12.1.4 NUMA 다중 프로세서

단일 버스 UMA 다중 프로세서는 일반적으로 수십 개의 CPU로 제한되며 크로스오버 또는 스왑 다중 프로세서에는 많은 (비싼) 하드웨어가 필요하며 그다지 크지 않습니다. 100개 이상의 CPU를 얻으려면 뭔가 비용을 지불해야 하며 일반적으로 모든 메모리 모듈은 동일한 액세스 시간을 갖습니다. 이러한 양보로 인해 위에서 언급한 대로 NUMA 다중 프로세서라는 아이디어가 탄생했습니다. UMA와 마찬가지로 모든 CPU에 단일 주소 공간을 제공하지만 UMA 시스템과 달리 로컬 메모리 모듈에 액세스하는 것이 원격 메모리 모듈에 액세스하는 것보다 빠릅니다. 따라서 모든 UMA 프로그램은 변경 없이 NUMA 시스템에서 실행되지만 성능은 UMA 시스템보다 저하됩니다.

NUMA 시스템에는 다른 멀티프로세서와 구별되는 세 가지 주요 특성이 있습니다.

  1. 모든 CPU에는 가시적인 주소 공간이 있습니다.

  2. LOAD 및 STORE 명령어를 통해 원격 메모리에 액세스합니다.

  3. 원격 메모리에 액세스하는 것이 로컬 메모리에 액세스하는 것보다 느립니다.

원격 메모리에 대한 액세스 시간이 숨겨지지 않은 경우(캐시가 없기 때문에) 시스템을 NC-NUMA(non-cache Consistency NUMA)라고 합니다. 캐시가 일관성이 있는 경우 시스템을 CC-NUMA(Cache Coherent NUMA)라고 합니다.

대규모 CC-NUMA 다중 프로세서를 구축하는 데 널리 사용되는 접근 방식은 디렉터리 기반 다중 프로세서입니다. 아이디어는 각 캐시 라인의 위치와 상태를 알려주는 데이터베이스를 유지하는 것입니다. 캐시 라인을 참조하면 데이터베이스에 쿼리를 보내 해당 위치와 깨끗한지 더러운지 여부를 알아냅니다. 이 데이터베이스는 메모리와 관련된 모든 명령에 대해 쿼리되기 때문에 버스 주기의 일부 내에 응답할 수 있는 매우 빠르고 특수한 하드웨어에 보관되어야 합니다.

디렉토리 기반 멀티프로세서의 아이디어를 보다 구체적으로 만들기 위해 각 노드가 CPU와 16MB RAM으로 구성되고 로컬 버스를 통해 CPU에 연결된 256노드 시스템의 간단한(가상) 예를 생각해 보겠습니다. 총 메모리는 232 바이트이고, 226 ​​캐시 라인으로 나누어지며, 각 캐시 라인은 64바이트입니다. 메모리는 노드들 사이에 정적으로 할당되는데, 노드 0에서는 0~16M, 노드 1에서는 16M~32M 등이다. 노드들은 아래 그림(a)와 같이 상호 연결 네트워크를 통해 연결된다. 또한 각 노드는 2^{24}바이트 메모리 중 218개의 64바이트 캐시 라인을 포함하는 디렉터리 항목을 유지 관리합니다. 현재 우리는 행이 최대 하나의 캐시에 보관될 수 있다고 가정합니다.

(a) 256노드 카탈로그 기반 멀티프로세서. (b) 32비트 메모리 주소를 필드로 나눕니다. (c) 노드 36의 디렉터리.

18.12.1.5 멀티코어 칩

칩 제조 기술이 발전함에 따라 트랜지스터는 점점 더 작아지고, 칩 위에 더 많은 트랜지스터를 탑재할 수 있게 되었습니다. 이 법칙은 무어의 법칙(Moore's Law)이라고 불리며 인텔 공동 창업자인 고든 무어(Gordon Moore)가 처음 발견했습니다. 1974년 Intel 8080에는 2,000개 이상의 트랜지스터가 포함되었으며 Xeon Nehalem EX CPU에는 20억 개 이상의 트랜지스터가 포함되었습니다.

분명한 질문은 이 트랜지스터가 무엇에 사용되는가입니다. 한 가지 옵션은 칩에 메가바이트의 캐시를 추가하는 것입니다. 4x 32MB 온칩 캐시가 있는 칩이 일반적이지만 어느 시점에서 캐시 크기를 늘리면 적중률이 99%에서 99.5%로 증가할 뿐 애플리케이션 성능이 크게 향상되지는 않습니다.

또 다른 옵션은 두 개 이상의 완전한 CPU(종종 코어라고 함)를 동일한 칩(기술적으로는 동일한 칩)에 배치하는 것입니다. 듀얼코어, 쿼드코어, 옥타코어 칩은 이미 일반화되어 있으며 수백 개의 코어가 있는 칩을 구입할 수도 있습니다. 더 많은 코어가 번성하고 있다는 것은 의심의 여지가 없습니다. 캐시는 여전히 중요하며 칩 전체에 분산되어 있습니다. 예를 들어 Intel Xeon 2651에는 12개의 물리적 하이퍼 스레드 코어가 있어 24개의 가상 코어를 제공합니다. 12개의 물리적 코어 각각에는 32KB L1 명령 캐시와 32KB L2 데이터 캐시가 있고, 각각 256KB L2 캐시가 있으며, 12개 코어는 30MB L3 캐시를 공유합니다.

CPU는 캐시를 공유할 수도 있고 공유하지 않을 수도 있지만 항상 주 메모리를 공유하며, 이 메모리는 각 메모리 단어가 고유한 값을 갖는다는 점에서 일관됩니다. 특수 하드웨어 회로는 두 개 이상의 캐시에 단어가 존재하고 CPU 중 하나가 단어를 수정하는 경우 일관성을 유지하기 위해 모든 캐시에서 해당 단어가 자동으로 제거되도록 보장합니다. 이 프로세스를 스누핑이라고 합니다.

이 설계의 결과로 멀티코어 칩은 매우 작은 멀티프로세서에 불과합니다. 실제로 멀티 코어 칩을 CMP(Chip MultiProcessors)라고도 합니다. 소프트웨어 관점에서 CMP는 버스 기반 멀티프로세서나 스위치 네트워크를 사용하는 멀티프로세서와 크게 다르지 않습니다. 그러나 여전히 몇 가지 차이점이 있습니다. 첫째, 버스 기반 멀티프로세서에서 각 CPU에는 AMD에서 자주 사용하는 자체 캐시가 있습니다. 공유 캐시 디자인은 Intel의 많은 프로세서에서 사용되며 다른 다중 프로세서에는 존재하지 않습니다. 공유 L2 또는 L3 캐시는 성능에 영향을 미칠 수 있습니다. 한 코어에 많은 캐시 메모리가 필요하고 다른 코어에는 그렇지 않은 경우 이 설계를 통해 캐시 보유자는 필요한 모든 것을 얻을 수 있습니다. 반면, 공유 캐시를 사용하면 탐욕스러운 코어가 다른 코어에 해를 끼칠 수도 있습니다.

CMP가 더 큰 사촌과 다른 영역 중 하나는 내결함성입니다. CPU가 너무 긴밀하게 연결되어 있기 때문에 공유 구성 요소에 오류가 발생하면 여러 CPU가 동시에 성능을 잃을 수 있는데, 이는 기존 다중 프로세서에서는 발생할 가능성이 거의 없습니다.

모든 코어가 동일한 대칭형 멀티 코어 칩 외에도 멀티 코어 칩의 또 다른 일반적인 범주는 SoC(System On a Chip)입니다. 이러한 칩에는 하나 이상의 메인 CPU가 있을 뿐만 아니라 비디오 및 오디오 디코더, 암호화 프로세서, 네트워크 인터페이스 등과 같은 전용 코어도 있어 칩에 완전한 컴퓨터 시스템을 형성합니다.

18.12.1.6 매니코어 칩

멀티코어는 단순히 "2개 이상의 코어"를 의미하지만, 코어 수가 계산 범위를 훨씬 벗어나는 경우에는 다른 이름을 사용합니다. **멀티코어 칩(Manycore)**은 수십, 수백, 수천 개의 코어를 포함하는 멀티코어 칩입니다. 멀티코어가 매니코어가 되기 위한 엄격한 기준은 없지만, 간단한 차이점은 더 이상 코어 한두 개 손실에 신경쓰지 않는다면 아마도 다중 코어를 갖게 될 것이라는 것입니다.

Intel의 Xeon Phi와 같은 가속기 플러그인 카드에는 60개 이상의 x86 코어가 있습니다. 다른 공급업체에서는 다양한 종류의 코어를 사용하여 100개 코어 장벽을 허물었습니다. 수천 개의 범용 코어가 작업 중일 수 있습니다. 프로그래밍 방법은커녕 수천 개의 코어를 처리하는 방법도 상상하기 어렵습니다.

코어 수가 많을 때 발생하는 또 다른 문제는 캐시 일관성을 유지하는 데 필요한 시스템이 매우 복잡해지고 비용이 많이 든다는 것입니다. 많은 엔지니어들은 캐시 일관성이 수백 개의 코어로 확장되지 않을 수 있다고 걱정합니다. 심지어 어떤 사람들은 그것을 완전히 버려야 한다고 주장하기도 합니다. 그들은 하드웨어의 일관성 프로토콜 비용이 너무 높아서 프로세서가 캐시를 일관성 있는 상태로 유지하는 데 너무 바빠서 빛나는 새 코어가 성능에 큰 도움이 되지 않을 것이라고 걱정합니다. 더 나쁜 것은 이 작업을 수행하려면 (빠른) 디렉터리에 너무 많은 메모리가 필요하다는 것입니다. 이를 일관성 벽이라고 합니다.

예를 들어 위에서 논의한 디렉터리 기반 캐시 일관성 솔루션을 생각해 보세요. 각 디렉토리 항목에 특정 캐시 라인이 포함된 코어를 나타내는 비트 벡터가 포함된 경우 코어가 1024개인 CPU에 대한 디렉토리 항목의 길이는 최소 128바이트입니다. 캐시 라인 자체는 128바이트보다 큰 경우가 거의 없기 때문에 디렉터리 항목이 추적하는 캐시 라인보다 커지는 어색한 상황이 발생합니다. 아마도 우리가 원하는 것은 아닐 것입니다.

일부 엔지니어들은 많은 수의 프로세서로 확장할 수 있는 유일한 프로그래밍 모델은 미래의 멀티 코어 칩에서 기대할 수 있는 메시지 전달 및 분산 메모리를 사용하는 모델이라고 믿습니다. Intel의 48코어 SCC와 같은 실험적인 프로세서는 캐시 일관성을 감소시켰으며 보다 빠른 메시지 전달을 위한 하드웨어 지원을 제공합니다. 반면에 다른 프로세서는 코어 수가 많아도 일관성을 제공합니다. 혼합 모드도 가능합니다. 예를 들어, 1024코어 칩은 64개의 아일랜드로 분할될 수 있으며, 각 아일랜드에는 16개의 캐시 일관성 코어가 있으며, 아일랜드 간의 캐시 일관성은 포기됩니다.

수천 개의 코어는 더 이상 특별하지 않습니다. 오늘날 많은 코어 중 가장 일반적인 것인 그래픽 처리 장치는 내장되어 있지 않고 모니터가 있는 거의 모든 컴퓨터 시스템에서 찾을 수 있습니다. GPU는 전용 메모리와 수천 개의 작은 코어를 갖춘 프로세서입니다. 범용 프로세서에 비해 GPU는 계산을 수행하는 회로에 더 많은 트랜지스터 예산을 소비하고 캐시 및 제어 논리에는 더 적은 비용을 소비합니다. 그래픽 응용 프로그램에서 다각형을 렌더링하는 것과 같이 많은 작은 계산을 병렬로 수행하는 데 적합합니다. 그들은 연속적인 작업을 잘 하지 못하고 프로그래밍하기도 어렵습니다. GPU는 운영 체제(예: 네트워크 트래픽 암호화 또는 처리)에 유용하지만 운영 체제 자체가 GPU에서 실행될 가능성은 거의 없습니다.

다른 컴퓨팅 작업, 특히 과학 컴퓨팅에서 흔히 발생하는 계산량이 많은 작업을 GPU에서 처리하는 일이 점점 더 늘어나고 있습니다. GPU의 범용 처리에 사용되는 용어는 GPGPU입니다. 불행하게도 GPU를 효율적으로 프로그래밍하는 것은 어렵고 OpenGL이나 NVIDIA의 독점 CUDA와 같은 특수 프로그래밍 언어가 필요합니다. GPU 프로그래밍과 범용 프로세서 프로그래밍의 중요한 차이점은 GPU가 본질적으로 "단일 명령 다중 데이터" 기계라는 것입니다. 즉, 다수의 코어가 정확히 동일한 명령을 실행하지만 데이터는 다릅니다. 이 프로그래밍 모델은 데이터 병렬 처리에는 적합하지만 작업 병렬 처리와 같은 다른 프로그래밍 스타일에는 항상 편리한 것은 아닙니다.

18.12.1.7 이기종 멀티코어

일부 칩은 GPU와 여러 범용 코어를 동일한 칩에 통합합니다. 마찬가지로 많은 SoC에는 하나 이상의 특수 프로세서 외에 범용 코어가 포함되어 있습니다. 여러 가지 유형의 프로세서를 단일 칩에 통합하는 시스템을 통칭하여 이기종 멀티 코어 프로세서라고 합니다. 이기종 멀티 코어 프로세서의 예로는 Intel이 2000년에 처음 출시했으며 최신 기술로 정기적으로 업데이트되는 IXP 네트워크 프로세서 제품군이 있습니다. 네트워크 프로세서는 일반적으로 범용 제어 코어(예: Linux를 실행하는 ARM 프로세서)와 네트워크 패킷 처리에 매우 능숙한 수십 개의 고도로 전문화된 스트림 프로세서로 구성되며 일반적으로 라우터 및 방화벽과 같은 네트워크 장치에 사용되는 다른 프로세서는 많지 않습니다. 반면, 고속 네트워크는 메모리에 대한 빠른 액세스(패킷 읽기)에 크게 의존하며 스트림 프로세서에는 이를 달성하기 위한 특수 하드웨어가 있습니다.

IXP의 스트림 프로세서와 제어 프로세서는 GPU 및 범용 코어와 마찬가지로 명령 세트도 다르며 완전히 다릅니다. 그러나 동일한 명령어 세트를 유지하면서 이질성을 도입하는 것이 가능합니다. 예를 들어, CPU에는 파이프라인이 깊고 클럭 속도가 높을 수 있는 소수의 "큰" 코어와 더 단순하고 덜 강력하며 잠재적으로 더 낮은 주파수에서 실행되는 더 많은 "작은" 코어가 있을 수 있습니다. 빠른 순차 처리가 필요한 코드를 실행하려면 강력한 코어가 필요한 반면, ARM의 big.LITTLE 프로세서 제품군과 같이 병렬로 효율적으로 실행할 수 있는 작업에는 작은 코어가 유용합니다.

18.12.2 다중 프로세서 운영 체제 유형

이제 다중 프로세서 하드웨어에서 다중 프로세서 소프트웨어, 특히 다중 프로세서 운영 체제로 이동해 보겠습니다. 다양한 방법이 가능하며 그 중 세 가지를 아래에서 살펴보겠습니다. 이 모든 사항은 멀티 코어 시스템뿐만 아니라 개별 CPU가 있는 시스템에도 동일하게 적용됩니다.

18.12.2.1 CPU별 운영 체제

다중 프로세서 운영 체제를 구성하는 가장 간단한 방법은 메모리를 가능한 많은 파티션으로 정적으로 나누고 각 CPU에 고유한 개인 메모리와 운영 체제의 개인 복사본을 제공하는 것입니다. 실제로 n개의 CPU는 n개의 독립적인 컴퓨터로 작동합니다. 확실한 최적화는 아래 그림과 같이 모든 CPU가 운영 체제 코드를 공유하고 운영 체제 데이터 구조의 개인 복사본만 만들도록 허용하는 것입니다.

다중 프로세서 메모리를 4개의 CPU로 나누지만 운영 체제 코드의 단일 복사본을 공유합니다. 데이터라고 표시된 상자는 각 CPU에 대한 운영 체제별 데이터입니다.

이 솔루션은 모든 컴퓨터가 디스크 세트와 기타 I/O 장치를 공유할 수 있고 메모리를 유연하게 공유할 수 있기 때문에 n개의 별도 컴퓨터를 사용하는 것보다 훨씬 좋습니다. 예를 들어, 정적 메모리 할당을 사용하더라도 CPU는 대용량 프로그램을 효율적으로 처리할 수 있도록 초대형 메모리를 확보할 수 있습니다. 또한 생산자는 데이터를 메모리에 직접 쓸 수 있고 소비자는 생산자가 작성한 곳에서 데이터를 얻을 수 있으므로 프로세스는 서로 효율적으로 통신할 수 있습니다. 그러나 운영 체제 관점에서 볼 때 각 CPU가 자체 운영 체제를 갖는 것은 원시적입니다.

이 디자인에는 명확하지 않을 수 있는 네 가지 측면이 있다는 점을 언급할 가치가 있습니다.

첫째, 프로세스가 시스템 호출을 하면 시스템 호출은 운영 체제 테이블의 데이터 구조를 사용하여 자체 CPU에서 캡처되고 처리됩니다.

둘째, 각 운영 체제에는 자체 테이블이 있으므로 자체적으로 예약할 수 있는 자체 프로세스 집합도 있습니다. 공유 프로세스가 없습니다. 사용자가 CPU1에 로그인하면 모든 프로세스가 CPU1에서 실행됩니다. 따라서 CPU2가 작업을 로드하는 동안 CPU1은 유휴 상태일 수 있습니다.

셋째, 실제 페이지는 공유되지 않습니다. CPU2가 지속적으로 페이징하는 동안 CPU1에는 사용 가능한 페이지가 있을 수 있습니다. 메모리 할당은 고정되어 있으므로 CPU 2는 CPU 1에서 일부 페이지를 빌릴 수 없습니다.

넷째, 최악의 경우 운영 체제가 최근에 사용된 디스크 블록의 버퍼 캐시를 유지 관리하는 경우 각 운영 체제는 서로 독립적으로 이를 수행합니다. 따라서 디스크 블록이 동시에 두 개 이상의 버퍼 캐시에 존재하고 더러워져 결과가 일관되지 않는 경우가 발생할 수 있습니다. 이 문제를 피하는 유일한 방법은 버퍼 캐시를 제거하는 것입니다. 이를 수행하는 것은 어렵지 않지만 성능에 심각한 영향을 미칠 수 있습니다.

이러한 이유로 이 모델은 더 이상 생산 시스템에서 거의 사용되지 않습니다. 각 프로세서의 모든 상태가 해당 프로세서에 대해 로컬로 유지되는 경우 공유가 거의 또는 전혀 이루어지지 않으면 일관성 또는 잠금 문제가 발생할 수 있습니다. 이와 대조적으로, 여러 프로세서가 동일한 프로세스 테이블에 액세스하고 수정해야 하는 경우 잠금은 빠르게 복잡해지고 성능에 중요해질 수 있습니다.

18.12.2.2 마스터-슬레이브 다중 프로세서

두 번째 모델은 아래에 나와 있습니다. 여기서 운영 체제 및 해당 테이블의 복사본은 CPU1에만 존재하고 다른 모델에는 존재하지 않습니다. 모든 시스템 호출은 처리를 위해 CPU1로 리디렉션되며, CPU 시간이 남아 있으면 CPU1은 사용자 프로세스를 실행할 수도 있습니다. 이 모델을 마스터-슬레이브라고 합니다. CPU 1이 마스터이고 다른 CPU가 슬레이브이기 때문입니다.

마스터-슬레이브 다중 프로세서 모델.

마스터-슬레이브 모델은 첫 번째 모델의 문제를 대부분 해결합니다. 준비된 프로세스를 추적하는 별도의 데이터 구조(예: 목록 또는 우선순위 목록 집합)를 갖습니다. CPU가 유휴 상태이면 CPU 1의 운영 체제에 프로세스를 실행하고 프로세스를 할당하도록 요청합니다. 따라서 한 CPU가 유휴 상태이고 다른 CPU가 과부하되는 일은 결코 발생하지 않습니다. 마찬가지로 페이지는 모든 프로세스에 걸쳐 동적으로 할당될 수 있으며 버퍼 캐시는 하나만 있으므로 불일치가 발생할 수 없습니다.

이 모델의 문제점은 CPU가 많으면 메인 CPU가 모든 CPU의 모든 시스템 호출을 처리해야 하기 때문에 병목 현상이 발생한다는 것입니다. 예를 들어 전체 시간의 10%가 시스템 호출을 처리하는 데 사용된다면 10개의 CPU는 호스트를 거의 포화 상태로 만들고 20개의 CPU는 호스트를 완전히 과부하하게 됩니다. 따라서 이 모델은 소형 다중 프로세서에는 간단하고 적합하지만 대형 다중 프로세서에는 적합하지 않습니다.

18.12.2.3 대칭형 다중 프로세서

세 번째 모델인 SMP(Symmetric Multiprocessors)는 이러한 비대칭성을 제거합니다. 메모리에는 운영 체제의 복사본이 있지만 모든 CPU에서 이를 실행할 수 있습니다. 시스템 호출이 이루어지면 시스템 호출을 수행하는 CPU는 커널을 캡처하고 시스템 호출을 처리합니다. SMP 모델은 아래 그림에 나와 있습니다.

SMP 아키텍처 사례 1.

SMP 아키텍처 사례 2.

SMP 아키텍처 사례 3.

이 모델은 운영 체제 테이블 세트가 하나만 있기 때문에 프로세스와 메모리의 균형을 동적으로 조정하고, 메인 CPU가 없기 때문에 메인 CPU 병목 현상도 제거합니다. 그러나 이는 그 자체의 문제를 야기합니다. 특히 두 개 이상의 CPU가 동시에 운영 체제 코드를 실행하는 경우 재앙으로 이어질 가능성이 높습니다. 두 개의 CPU가 동일한 프로세스를 동시에 실행하도록 선택하거나 동일한 여유 메모리 페이지를 요청한다고 상상해 보세요. 이러한 문제를 해결하는 가장 간단한 방법은 뮤텍스(예: 잠금)를 운영 체제와 연결하여 전체 시스템을 하나의 큰 중요한 영역으로 만드는 것입니다. CPU가 운영 체제 코드를 실행하려면 먼저 뮤텍스를 획득해야 합니다. 뮤텍스가 잠겨 있는 경우에만 대기합니다. 이런 방식으로 모든 CPU는 운영 체제를 실행할 수 있지만 한 번에 하나만 실행할 수 있습니다. 이 방법을 빅 커널 잠금이라고 합니다.

이 패턴은 잘 작동하지만 마스터-슬레이브 패턴만큼 좋지 않습니다. 다시 말하지만, 전체 실행 시간의 10%가 운영 체제 내에서 내부적으로 소비된다고 가정합니다. CPU가 20개이면 입력을 기다리는 CPU 대기열이 길어집니다. 다행스럽게도 개선하기 쉽습니다. 운영 체제의 많은 부분이 서로 독립적입니다. 예를 들어 한 CPU는 스케줄러를 실행하고, 다른 CPU는 파일 시스템 호출을 처리하고, 세 번째 CPU는 페이지 오류를 처리하는데 문제가 없습니다.

이러한 관찰로 인해 운영 체제가 서로 상호 작용하지 않는 별도의 중요 영역으로 분할되었습니다. 각 중요 영역은 자체 뮤텍스로 보호되므로 한 번에 하나의 CPU만 실행할 수 있습니다. 이런 방식으로 더 많은 병렬성을 달성할 수 있습니다. 그러나 일부 테이블(예: 프로세스 테이블)은 둘 이상의 중요 영역에서 사용될 가능성이 높습니다. 예를 들어, 프로세스 테이블은 스케줄링뿐만 아니라 포크 시스템 호출 및 신호 처리에도 사용됩니다. 여러 키 영역에서 사용할 수 있는 각 테이블에는 자체 뮤텍스가 필요하므로 각 키 영역은 한 번에 하나의 CPU에서만 실행될 수 있고 각 키 테이블은 한 번에 하나의 CPU에서만 액세스할 수 있습니다.

대부분의 최신 다중 프로세서는 이런 종류의 스케줄링을 사용하며, 그러한 기계에 대한 운영 체제를 작성하는 데 어려움이 있는 것은 실제 코드가 일반 운영 체제와 매우 다르다는 것이 아닙니다. 가장 어려운 부분은 미묘하고 간접적인 방식이 아니더라도 서로 간섭하지 않고 서로 다른 CPU에서 동시에 실행할 수 있는 중요한 영역으로 나누는 것입니다. 또한 두 개 이상의 중요 영역에서 사용되는 각 테이블은 뮤텍스로 개별적으로 보호되어야 하며 해당 테이블을 사용하는 모든 코드는 뮤텍스를 올바르게 사용해야 합니다.

또한 교착 상태를 방지하려면 세심한 주의를 기울여야 합니다. 두 개의 중요한 영역에 테이블 A와 테이블 B가 필요하고 하나는 A를 먼저 가져오고 다른 하나는 B를 먼저 가져오면 조만간 교착 상태가 발생하고 그 이유를 아무도 알 수 없습니다. 이론적으로 모든 테이블에는 정수 값이 할당될 수 있으며 모든 키 영역은 테이블에서 오름차순으로 얻을 수 있습니다. 이 전략은 교착 상태를 방지하지만 프로그래머는 각 중요 영역에 어떤 테이블이 필요한지 매우 신중하게 생각하고 올바른 순서로 요청해야 합니다.

코드가 계속 발전함에 따라 중요한 영역에는 이전에는 필요하지 않았던 새로운 테이블이 필요할 수 있습니다. 프로그래머가 초보이고 시스템의 전체 논리를 이해하지 못한다면 필요할 때 테이블에 있는 뮤텍스를 잡고 더 이상 필요하지 않을 때 해제하고 싶은 유혹이 생길 것입니다. 이것이 아무리 합리적으로 보일지라도 사용자가 시스템 정지로 해석하는 교착 상태가 발생할 수 있습니다. 이를 수행하는 것은 쉽지 않으며 프로그래머가 바뀌는 상황에서 일정 기간 동안 이를 유지하는 것도 매우 어렵습니다.

18.12.3 다중 프로세서 동기화

다중 프로세서의 CPU는 종종 동기화가 필요합니다. 앞서 커널의 중요한 영역과 테이블은 뮤텍스로 보호되어야 한다는 점을 살펴보았습니다. 다중 프로세서에서 이 동기화가 어떻게 작동하는지 살펴보겠습니다.

첫째, 적절한 동기화 프리미티브가 실제로 필요합니다. 단일 프로세서 시스템(CPU 1개)의 프로세스가 중요한 커널 테이블에 액세스해야 하는 시스템 호출을 수행하는 경우 커널 코드는 해당 테이블에 액세스하기 전에 인터럽트를 비활성화할 수 있습니다. 그러면 작업이 완료되기 전에 추가 프로세스 없이 작업을 수행할 수 있다는 것을 알기 때문에 작업을 수행할 수 있습니다. 다중 프로세서에서 인터럽트를 비활성화하면 비활성화된 작업을 수행하는 CPU에만 영향을 줍니다. 다른 CPU는 계속 실행되며 중요한 테이블을 계속 조작할 수 있습니다. 따라서 모든 CPU는 뮤텍스가 작동하도록 적절한 뮤텍스 프로토콜을 사용하고 준수해야 합니다.

실용적인 뮤텍스 프로토콜의 핵심은 분할할 수 없는 한 번의 작업으로 메모리 워드를 확인하고 설정할 수 있는 특수 명령입니다. 중요 영역은 TSL(Test and Set Lock)을 사용하여 구현할 수 있습니다. 앞서 언급했듯이 TSL의 역할은 메모리 단어를 읽고 이를 레지스터에 저장하는 것입니다. 동시에 메모리 워드에 1(또는 0이 아닌 다른 값)을 씁니다. 물론, 메모리 읽기와 메모리 쓰기를 수행하려면 두 개의 버스 사이클이 필요합니다. 단일 프로세서에서 TSL은 명령이 중간에 중단될 수 없는 한 항상 예상대로 작동합니다.

이제 다중 프로세서에서 어떤 일이 일어나는지 생각해보십시오. 아래 이미지에서는 잠금으로 사용된 메모리 워드 1000이 초기에 0인 최악의 타이밍을 볼 수 있습니다. 1단계에서 CPU 1은 단어를 읽고 0을 얻습니다. 2단계에서 CPU 1이 단어를 1로 다시 쓸 기회를 갖기 전에 CPU 2가 들어와서 그 단어를 0으로 읽습니다. 3단계에서 CPU는 이 단어에 1을 씁니다. 4단계에서 CPU 2도 단어에 1을 씁니다. 두 CPU 모두 TSL 명령에서 0을 얻었으므로 이제 둘 다 중요 영역에 액세스할 수 있으며 상호 배제가 실패했습니다.

버스를 잠글 수 없으면 TSL 명령이 실패할 수 있습니다. 이 네 단계는 실패가 드러나는 일련의 이벤트를 보여줍니다.

이 문제를 방지하려면 TSL 명령어는 먼저 버스를 잠가서 다른 CPU가 버스에 액세스하지 못하도록 한 다음 두 번의 메모리 액세스를 수행하고 버스를 잠금 해제해야 합니다. 일반적으로 버스는 일반적인 버스 요청 프로토콜을 사용하여 요청한 다음 두 사이클이 완료될 때까지 일부 특수 버스를 어설션(즉, 논리 1 값으로 설정)하여 잠깁니다. 이 특정 라인이 지정되는 한 다른 CPU에는 버스 액세스 권한이 부여되지 않습니다. 이 명령은 이를 사용하는 데 필요한 라인과 (하드웨어) 프로토콜이 있는 버스에서만 구현할 수 있습니다. 현대 버스에는 이러한 기능이 있지만, 이러한 기능이 없었던 이전 버스에서는 TSL을 올바르게 구현하는 것이 불가능했습니다. 이것이 바로 소프트웨어에서 완전히 동기화되는 Peterson 프로토콜이 발명된 이유입니다.

TSL이 올바르게 구현되고 사용되면 뮤텍스가 작동할 수 있음이 보장됩니다. 그러나 요청하는 CPU가 긴밀한 루프에 있기 때문에 이 뮤텍스 접근 방식은 스핀 잠금을 사용하여 가능한 한 빨리 잠금을 테스트합니다. 이는 요청하는 CPU(또는 CPU)의 시간을 완전히 낭비할 뿐만 아니라 버스나 메모리에 막대한 부하를 가해 다른 모든 CPU가 제대로 작동하는 것을 심각하게 저하시킬 수도 있습니다.

언뜻 보면 캐시가 있으면 버스 경합 문제가 해결되는 것처럼 보이지만 그렇지 않습니다. 이론적으로 요청하는 CPU가 잠금 단어를 읽으면 캐시에 복사본을 가져와야 합니다. 다른 CPU가 잠금을 사용하려고 시도하지 않는 한 요청하는 CPU는 캐시를 모두 소진할 수 있어야 합니다. 잠금을 보유하고 있는 CPU가 잠금을 해제하기 위해 잠금에 0을 쓰면 캐시 프로토콜은 원격 캐시의 모든 복사본을 자동으로 무효화하므로 올바른 값을 다시 가져오도록 요구합니다.

문제는 캐시가 32바이트나 64바이트 블록으로 동작한다는 점이다. 일반적으로 잠금을 둘러싼 단일 단어는 잠금을 보유한 CPU에 필요하며 TSL 명령어는 쓰기이므로(잠금을 수정하므로) 잠금이 포함된 캐시 블록에 대한 단독 액세스가 필요합니다. 따라서 각 TSL은 잠금 보유자의 캐시에 있는 블록을 무효화하고 요청 CPU에 대한 전용, 배타적 복사본을 얻습니다. 잠금 보유자가 잠금에 인접한 단어를 터치하면 캐시 블록이 해당 머신으로 이동됩니다. 따라서 잠금을 포함하는 전체 캐시 블록은 잠금 소유자와 잠금 요청자 사이에서 지속적으로 이동하여 잠금 단어에 대한 단일 읽기를 초과하는 버스 트래픽을 생성합니다.

요청 측에서 모든 TSL 유도 쓰기를 제거할 수 있는 경우 요청 CPU가 먼저 순수 읽기를 수행하여 잠금이 사용 가능한지 확인함으로써 캐시 스래싱을 ​​크게 줄일 수 있습니다. 잠금이 해제된 것처럼 보일 때만 실제로 잠금을 획득하기 위해 TSL을 실행합니다. 이 작은 변화의 결과로 대부분의 폴링은 쓰기보다는 읽기가 됩니다. 잠금을 유지하는 CPU가 동일한 캐시 블록의 변수만 읽는 경우 공유 읽기 전용 모드에서 캐시 블록의 복사본을 각각 가질 수 있으므로 모든 캐시 블록 전송이 제거됩니다.

잠금이 최종적으로 해제되면 소유자는 단독 액세스가 필요한 쓰기 작업을 수행하여 원격 캐시의 모든 복사본을 무효화합니다. 캐시 블록은 다음에 CPU가 읽기를 요청할 때 다시 로드됩니다. 두 개 이상의 CPU가 동일한 잠금을 위해 경쟁하는 경우 두 CPU가 동시에 유휴 상태임을 확인하고 두 CPU가 TSL을 실행하여 동시에 잠금을 획득하는 일이 발생할 수 있습니다. 그 중 하나만 성공하므로 실제 가져오기는 TSL 명령어에 의해 수행되고 원자적이므로 여기에는 경쟁 조건이 없습니다. 잠금이 무료임을 확인하고 TSL을 사용하여 즉시 잠금을 얻으려고 한다고 해서 잠금을 얻을 수 있다는 보장은 없습니다. 그러나 알고리즘의 정확성을 위해서는 누가 그것을 얻는지는 중요하지 않습니다. 순수 읽기의 성공은 잠금 획득을 시도하기에 좋은 시기라는 것을 의미할 뿐이지 성공을 보장하지는 않습니다.

버스 트래픽을 줄이는 또 다른 방법은 연속 폴링을 사용하여 폴 사이에 지연 루프를 삽입하는 잘 알려진 이더넷 바이너리 지수 백오프 알고리즘을 사용하는 것입니다. 처음에는 명령 하나가 지연되고 잠금이 계속 사용 중인 경우 지연은 명령 2개, 명령 4개 등 최대값에 도달할 때까지 두 배로 늘어납니다. 최대값이 낮으면 잠금을 해제할 때 빠른 응답을 제공하지만 캐시 스래싱에 더 많은 버스 주기를 낭비합니다. 최대값이 높으면 캐시 스래싱을 ​​줄일 수 있지만 너무 빨리 해제되는 잠금을 알아차리지 못하는 대가를 치르게 됩니다. 이진 지수 폴백은 TSL 명령어 이전의 순수 읽기 유무에 관계없이 사용할 수 있습니다.

더 좋은 아이디어는 아래 이미지에 표시된 것처럼 뮤텍스를 획득하려는 각 CPU가 테스트용 개인 잠금 변수를 갖도록 하는 것입니다. 충돌을 피하기 위해 변수는 사용되지 않는 다른 캐시 블록에 위치해야 합니다. 이 알고리즘은 잠금을 획득할 수 없는 CPU가 잠금 변수를 할당하고 잠금을 기다리는 CPU 목록 끝에 자신을 추가하도록 하여 작동합니다. 현재 잠금 보유자가 중요 영역을 종료하면 목록의 첫 번째 CPU가 테스트하고 있던 개인 잠금(자체 캐시에 있음)을 해제합니다. 그런 다음 CPU는 임계 영역으로 들어갑니다. 완료되면 후속 작업에서 사용 중인 잠금이 해제됩니다. 프로토콜은 약간 복잡하지만(두 CPU가 동시에 목록 끝에 연결되는 것을 방지하기 위해) 효율적이고 기아 현상이 없습니다.

캐시 스래싱을 방지하려면 여러 잠금을 사용하세요.

18.12.3.1 회전 및 전환

지금까지 우리는 뮤텍스를 잠가야 하는 CPU가 연속 폴링, 간헐적 폴링 또는 대기 CPU 목록에 자신을 추가하여 단순히 이를 기다린다고 가정했습니다. 때로는 요청하는 CPU가 기다릴 수밖에 없는 경우도 있습니다. 예를 들어 CPU가 유휴 상태이고 실행할 프로세스를 선택하기 위해 공유 준비 목록에 액세스해야 한다고 가정합니다. 준비 목록이 잠겨 있으면 CPU는 수행 중인 작업을 일시 중지하고 다른 프로세스를 실행하기로 결정할 수 없습니다. 그렇게 하려면 준비 목록을 읽어야 하기 때문입니다. 준비 목록을 얻을 수 있을 때까지 기다려야 합니다.

그러나 다른 경우에는 옵션이 있습니다. 예를 들어, CPU의 스레드가 파일 시스템 버퍼 캐시에 액세스해야 하고 해당 캐시가 현재 잠겨 있는 경우 CPU는 기다리는 대신 다른 스레드로 전환하기로 결정할 수 있습니다. 스레드를 스핀할지 아니면 전환할지에 대한 질문은 연구의 문제였습니다. 다른 CPU가 잠금을 해제하지 않으면 회전이 의미가 없기 때문에 이 문제는 단일 프로세서에서는 발생하지 않습니다. 스레드가 잠금을 획득하려고 시도하고 실패하는 경우 잠금 소유자에게 잠금을 실행하고 해제할 수 있는 기회를 제공하기 위해 항상 차단됩니다.

스핀과 실행 스레드 전환이 모두 실행 가능한 옵션이라고 가정하면 장단점은 다음과 같습니다. 스핀은 CPU 주기를 직접적으로 낭비하며 잠금을 반복적으로 테스트하는 것은 생산적인 작업이 아닙니다. 그러나 전환은 현재 스레드의 상태를 저장하고, 준비 목록에 대한 잠금을 획득하고, 스레드를 선택하고, 해당 상태를 로드하고, 스레드를 시작해야 하기 때문에 CPU 주기를 낭비하기도 합니다. 또한 CPU 캐시에는 모든 불량 블록이 포함되므로 새 스레드가 실행되기 시작하면 비용이 많이 드는 캐시 누락이 많이 발생합니다. TLB도 실패할 수 있습니다. 결국 원래 스레드로 다시 전환해야 하고 더 많은 캐시 누락이 발생합니다. 이러한 두 가지 컨텍스트 전환과 캐시 누락을 수행하는 데 소요된 주기는 낭비됩니다.

뮤텍스가 일반적으로 50마이크로초 동안 유지되고 현재 스레드에서 전환하는 데 1밀리초가 걸리고 나중에 다시 전환하는 데 1초가 걸린다는 것을 알고 있다면 뮤텍스를 회전시키는 것만으로도 더 효율적입니다. 반면에 평균 뮤텍스가 10밀리초 동안 유지된다면 두 번의 컨텍스트 전환을 수행할 가치가 있습니다. 문제는 중요한 영역의 기간이 크게 달라질 수 있다는 것입니다. 그러면 어떤 접근 방식이 더 낫습니까?

한 가지 디자인은 항상 회전하는 것이고, 두 번째는 항상 전환하는 것이지만, 세 번째는 잠긴 뮤텍스가 발생할 때마다 별도의 결정을 내리는 것입니다. 결정을 내려야 할 때 회전하는 것이 나은지 전환하는 것이 더 나은지는 알 수 없지만 특정 시스템에 대해 모든 활동은 나중에 오프라인으로 추적하고 분석할 수 있습니다. 돌이켜보면 어떤 결정이 최선이었고, 최선의 시나리오에서 얼마나 많은 시간이 낭비됐는지. 알고리즘의 사후 발견은 실행 가능한 알고리즘을 측정할 수 있는 벤치마크가 됩니다.

연구자들은 수십 년 동안 이 문제를 연구해 왔습니다. 대부분의 연구에서는 뮤텍스를 획득하지 못한 스레드가 일정 시간 동안 회전하는 모델을 사용합니다. 이 임계값을 초과하면 전환하십시오. 어떤 경우에는 일반적으로 다른 스레드로 전환했다가 다시 다시 전환하는 알려진 오버헤드로 인해 임계값이 고정됩니다. 다른 경우에는 동적이며 뮤텍스에서 관찰된 대기 기록에 따라 달라집니다.

시스템이 마지막으로 관찰된 몇 개의 회전 시간을 추적하고 이것이 이전 회전 시간과 유사하다고 가정할 때 최상의 결과를 얻을 수 있습니다. 예를 들어 또 다른 1ms 컨텍스트 전환을 가정하면 스레드는 최대 2ms 동안 회전하지만 실제로 회전하는 시간을 관찰하십시오. 잠금을 획득할 수 없고 처음 세 번의 실행 동안 평균 200마이크로초를 기다린 것으로 확인되면 전환하기 전에 2밀리초 동안 회전해야 합니다. 그러나 이전 시도에서 2밀리초 동안 회전한 것으로 확인되면 회전하는 대신 즉시 전환해야 합니다.

x86을 포함한 일부 최신 프로세서는 대기 시간을 보다 효율적으로 만들어 전력 소비를 줄이는 특수 지침을 제공합니다. 예를 들어, x86의 MONITOR/MWAIT 명령을 사용하면 다른 프로세서가 이전에 정의된 메모리 영역의 데이터를 수정할 때까지 프로그램이 차단될 수 있습니다. 특히 MONITOR 명령어는 쓰기를 모니터링해야 하는 주소 범위를 정의합니다. 그런 다음 MWAIT 명령은 누군가가 해당 영역에 쓸 때까지 스레드를 차단합니다. 실제로 스레드가 회전하고 있지만 불필요하게 많은 사이클을 소모하지는 않습니다.

18.12.4 다중 프로세서 스케줄링

다중 프로세서에서 스케줄링이 수행되는 방법을 살펴보기 전에 스케줄링되는 내용을 결정하는 것이 필요합니다. 과거에는 모든 프로세스가 단일 스레드였을 때는 프로세스가 예약되고 다른 프로세스는 예약될 수 없었습니다. 모든 최신 운영 체제는 다중 스레드 프로세스를 지원하므로 일정 관리가 더욱 복잡해집니다.

스레드가 커널 스레드인지 사용자 스레드인지는 중요합니다. 스레딩이 사용자 공간 라이브러리에 의해 수행되고 커널이 스레드에 대해 아무것도 모르는 경우 스케줄링은 항상 그랬던 것처럼 프로세스별로 수행됩니다. 스레드가 존재하는지조차 모르는 경우 커널이 스레드를 예약하기가 어렵습니다.

커널 스레드의 경우 상황이 다릅니다. 커널은 모든 스레드에 대해 알고 있으며 프로세스에 속한 스레드 중에서 선택할 수 있습니다. 이러한 시스템에서는 실행할 스레드를 커널이 선택하는 경향이 있으며 커널이 속한 프로세스는 스레드 선택 알고리즘에서 작은 역할만 수행하거나 전혀 역할을 하지 않습니다. 아래에서 스레드 예약에 대해 논의하겠지만, 물론 단일 스레드 프로세스나 스레드가 사용자 공간에 구현된 시스템에서는 프로세스가 예약됩니다.

프로세스와 스레드만이 스케줄링 문제가 아닙니다. 단일 프로세서에서 스케줄링은 1차원적입니다. (반복적으로) 대답해야 하는 유일한 질문은 "다음에 어떤 스레드를 실행해야 합니까?"입니다. 다중 프로세서에서 스케줄링에는 두 가지 차원이 있습니다. 스케줄러는 실행할 스레드와 CPU를 결정해야 합니다. 추가 차원으로 인해 다중 프로세서의 스케줄링이 매우 복잡해집니다. 또 다른 복잡한 요소는 일부 시스템에서는 모든 스레드가 서로 연결되어 있고 서로 다른 프로세스에 속하며 서로 관계가 없다는 것입니다. 다른 애플리케이션에서는 그룹화되어 동일한 애플리케이션에 속하며 함께 작동합니다. 전자 상황의 예로는 독립 사용자가 독립 프로세스를 시작하는 서버 시스템이 있습니다. 서로 다른 프로세스의 스레드는 서로 관련이 없으며 각 프로세스는 다른 프로세스를 고려하지 않고 예약될 수 있습니다.

후자 상황의 예는 종종 프로그램 개발 환경에서 발생합니다. 대규모 시스템은 일반적으로 실제 코드 파일에서 사용되는 매크로, 유형 정의 및 변수 선언이 포함된 몇 개의 헤더 파일로 구성됩니다. 헤더 파일을 변경하면 해당 파일이 포함된 모든 코드 파일을 다시 컴파일해야 합니다. 프로그램 make는 일반적으로 개발을 관리하는 데 사용됩니다. make가 호출되면 헤더 파일이나 코드 파일의 변경으로 인해 다시 컴파일해야 하는 코드 파일만 컴파일하기 시작합니다. 여전히 유효한 개체 파일은 재생성되지 않습니다.

make의 원래 버전은 순차적으로 실행되었지만 다중 프로세서용으로 설계된 최신 버전은 모든 컴파일을 동시에 시작할 수 있습니다. 10개의 컴파일이 필요한 경우 그 중 9개를 즉시 실행하고 마지막 컴파일을 훨씬 나중에 완료하도록 예약하는 것은 의미가 없습니다. 왜냐하면 사용자는 마지막 컴파일이 완료될 때까지 작업이 완료되었다고 느낄 수 없기 때문입니다. 이 경우 컴파일을 수행하는 스레드를 그룹으로 처리하고 이를 예약할 때 이를 고려하는 것이 합리적입니다.

때로는 생산자-소비자 방식과 같이 광범위하게 통신하는 스레드를 동시에뿐만 아니라 공간에서 밀접하게 함께 예약하여 공유 캐시의 이점을 누릴 수 있도록 예약하는 것이 유용할 수 있습니다. 또한 NUMA 아키텍처에서는 근처 메모리에 액세스하면 도움이 될 수 있습니다.

일반적인 스케줄링 알고리즘에는 시간 공유, 공간 공유, 그룹 스케줄링 등이 포함됩니다. 시간 공유는 이전에 소개되었으므로 아래에서는 후자 두 가지만 소개합니다.

18.12.4.1 공간 공유

스레드가 어떤 방식으로든 서로 관련되어 있는 경우 다중 프로세서 스케줄링에 대한 또 다른 일반적인 접근 방식을 사용할 수 있습니다. 앞서 우리는 병렬 make의 예를 언급했습니다. 일반적으로 프로세스에는 함께 작동하는 여러 스레드가 있습니다. 예를 들어 프로세스의 스레드가 자주 통신하는 경우 동시에 실행하는 것이 유용합니다. 여러 CPU에 걸쳐 동시에 여러 스레드를 예약하는 것을 공간 공유라고 합니다.

가장 간단한 공간 공유 알고리즘은 다음과 같이 작동합니다. 관련 스레드 그룹이 한 번에 생성된다고 가정하면, 생성될 때 스케줄러는 스레드 수만큼 유휴 CPU가 있는지 확인합니다. 그렇다면 각 스레드에는 자체 전용(즉, 다중 프로그래밍되지 않은) CPU가 있으며 모두 시작됩니다. CPU가 충분하지 않으면 스레드가 시작되지 않습니다. 각 스레드는 종료될 때까지 CPU를 점유하며, 종료되면 CPU는 사용 가능한 CPU 풀에 다시 배치됩니다. 스레드가 I/O를 차단하면 스레드가 깨어날 때까지 CPU를 계속 유휴 상태로 유지합니다. 다음 스레드 배치가 나타나면 동일한 알고리즘이 적용됩니다.

언제든지 CPU 세트는 여러 파티션으로 정적으로 나누어지며, 각 파티션은 프로세스의 스레드를 실행합니다. 아래 이미지에는 CPU 크기가 4, 6, 8, 12개인 파티션이 있습니다. 예를 들어 할당되지 않은 CPU가 2개 있습니다. 시간이 지남에 따라 새 스레드가 생성되고 이전 스레드가 완료되고 종료됨에 따라 파티션의 수와 크기가 변경됩니다.

32개의 CPU 세트는 2개의 CPU를 사용할 수 있는 4개의 파티션으로 나뉩니다.

일정 결정은 정기적으로 이루어져야 합니다. 최단 작업 우선은 단일 프로세서 시스템에서 잘 알려진 배치 스케줄링 알고리즘입니다. 멀티프로세서에 대한 유사한 알고리즘은 가장 적은 수의 CPU 주기가 필요한 프로세스, 즉 가장 작은 CPU 수 × 실행 시간을 갖는 스레드를 선택하는 것입니다. 그러나 실제로는 이 정보를 거의 사용할 수 없으므로 알고리즘을 구현하기가 어렵습니다. 실제로 연구에 따르면 선착순은 실제로 달성하기 어렵습니다.

이 간단한 분할 모델에서 스레드는 일부 CPU만 필요하며 이를 가져오거나 사용할 수 있을 때까지 기다립니다. 또 다른 접근 방식은 스레드가 병렬성을 적극적으로 관리하는 것입니다. 병렬 처리를 관리하는 한 가지 방법은 중앙 서버를 사용하여 실행 중인 스레드, 실행하려는 스레드, 최소 및 최대 CPU 요구 사항을 추적하는 것입니다. 각 응용 프로그램은 주기적으로 중앙 서버를 폴링하여 CPU를 얼마나 사용할 수 있는지 묻습니다. 그런 다음 사용 가능한 스레드 수에 맞게 스레드 수를 늘리거나 줄입니다.

예를 들어, 웹 서버에는 5개, 10개, 20개 또는 기타 임의 개수의 스레드가 병렬로 실행될 수 있습니다. 현재 10개의 스레드가 있는데 갑자기 CPU 수요가 증가하여 5개 스레드로 줄이라는 지시가 나오면 다음 5개의 스레드가 현재 작업을 마치면 새 작업을 제공하는 대신 종료하라는 지시를 받습니다. 이 구성표를 사용하면 위에 표시된 고정 시스템보다 현재 작업 부하에 더 잘 맞도록 파티션 크기를 동적으로 변경할 수 있습니다.

18.12.4.2 그룹 스케줄링

공간 공유의 분명한 이점은 다중 프로그래밍과 그에 따른 컨텍스트 전환 오버헤드가 제거된다는 것입니다. 그러나 똑같이 명백한 단점은 CPU가 차단되고 다시 준비되기 전에 할 일이 없을 때 시간이 낭비된다는 것입니다. 따라서 사람들은 시간과 공간을 동시에 예약할 수 있는 알고리즘, 특히 서로 통신해야 하는 경우가 많은 다중 스레드를 생성하는 알고리즘을 찾고 있었습니다.

프로세스의 스레드가 독립적으로 예약될 때 발생할 수 있는 문제를 이해하려면 스레드 A0과 A1이 프로세스 A에 속하고 스레드 B0과 B1이 프로세스 B에 속하는 시스템을 생각해 보세요. 스레드 A1과 B1은 CPU 1에서 시간 공유되며 스레드 A0과 A1은 자주 통신해야 합니다. 통신 패턴은 A0이 A1에게 메시지를 보내고 A1이 A0에게 응답을 보낸 다음 또 다른 시퀀스를 보내는 것인데, 이는 클라이언트-서버 상황에서 일반적입니다. 아래 그림과 같이 다행히 A0과 B1이 먼저 시작한다고 가정해 보겠습니다.

스레드 A에 속한 두 스레드 간의 역위상 통신.

타임 슬라이스 0에서 A0은 A1에 요청을 보내지만 A1은 타임 슬라이스 1에서 100밀리초에 실행을 시작할 때까지 요청을 받지 않습니다. 즉시 응답을 보내지만 A0은 200ms에 다시 실행될 때까지 응답을 받지 않습니다. 최종 결과는 200밀리초마다 요청-응답 시퀀스로 이루어지며 이는 성능이 그리 좋지 않습니다.

이 문제에 대한 해결책은 공동 스케줄링(Joint Scheduling)의 산물인 갱 스케줄링(Gang Scheduling)입니다. 이는 세 부분으로 구성됩니다.

  1. 관련 스레드 그룹은 하나의 단위 또는 그룹으로 배열됩니다.

  2. 갱의 모든 구성원은 동시에 서로 다른 시간 공유 CPU에서 실행됩니다.

  3. 모든 갱단원은 함께 시간 조각을 시작하고 끝냅니다.

패킷 스케줄링 작업을 수행하는 비결은 모든 CPU가 동기적으로 스케줄링된다는 것입니다. 즉, 위 그림에 표시된 것처럼 시간이 별개의 양으로 나누어집니다. 각각의 새로운 퀀텀이 시작될 때 모든 CPU의 일정이 변경되고 각 CPU에서 새 스레드가 시작됩니다. 다음 기간이 시작되면 또 다른 일정 이벤트가 발생합니다. 그 사이에는 예약이 발생하지 않습니다. 스레드가 차단되면 해당 CPU는 해당 기간이 끝날 때까지 유휴 상태로 유지됩니다.

아래 그림은 그룹 스케줄링 작업의 예를 보여줍니다. 5개의 프로세스 a~e에서 사용되는 6개의 CPU를 갖춘 멀티프로세서가 있으며 총 24개의 준비된 스레드가 있습니다. 시간 슬롯 0 동안 스레드 A0부터 A6까지 예약되고 실행됩니다. 시간 슬롯 1 동안 스레드 B0, B1, B2, C0, C1 및 C2가 예약되고 실행됩니다. 시간 슬롯 2 동안 D의 5개 스레드와 E0이 실행되기 시작합니다. 스레드 E에 속하는 나머지 6개 스레드는 슬롯 3에서 실행됩니다. 그런 다음 슬롯 4가 슬롯 0과 동일해지는 식으로 주기가 반복됩니다.

그룹화된 스케줄링의 개념은 프로세스의 모든 스레드가 동시에 다른 CPU에서 실행되도록 하여 스레드 중 하나가 다른 스레드에 요청을 보내면 거의 즉시 메시지를 받고 거의 즉시 응답할 수 있도록 하는 것입니다. 위 다이어그램에서는 모든 A 스레드가 함께 실행되므로 일정 기간 내에 양자 세그먼트에서 많은 수의 메시지를 보내고 받을 수 있으므로 위 다이어그램의 문제가 제거됩니다.

18.12.5 멀티코어 및 멀티스레딩

멀티 코어 시스템을 사용하여 워크스테이션, 비디오 게임 콘솔 또는 프로세서 집약적인 응용 프로그램을 실행하는 개인용 컴퓨터에 나타날 수 있는 것과 같은 여러 스레드가 있는 단일 응용 프로그램을 지원하면 성능 및 응용 프로그램 디자인 문제가 발생합니다. 이 섹션에서는 멀티 코어 시스템에서 멀티 스레드 애플리케이션이 성능에 미치는 영향 중 일부를 설명합니다.

멀티 코어 구성의 잠재적인 성능 이점은 애플리케이션에 사용 가능한 병렬 리소스를 효과적으로 활용하는 능력에 따라 달라집니다. 성능 매개변수는 Amdahl의 법칙을 따릅니다. 다음 두 그림은 멀티코어의 성능 영향 곡선을 보여줍니다.

일반 서버 소프트웨어 외에도 많은 애플리케이션 클래스가 코어 수에 따라 처리량을 확장하는 기능을 통해 직접적인 이점을 얻습니다. 다음은 몇 가지 예입니다.

  • 멀티스레드 기본 애플리케이션: 멀티스레드 애플리케이션은 소수의 고도로 스레드된 프로세스를 갖는 것이 특징입니다. 예로는 Lotus Domino 또는 Siebel CRM(Customer Relationship Manager)이 있습니다.

  • 다중 프로세스 애플리케이션: 다중 프로세스 애플리케이션은 단일 스레드 프로세스가 많다는 특징이 있습니다. 예로는 Oracle Database, SAP 및 PeopleSoft가 있습니다.

  • Java 애플리케이션: Java 애플리케이션은 기본적인 방식으로 스레드를 수용합니다. Java 언어는 다중 스레드 애플리케이션을 크게 용이하게 할 뿐만 아니라 Java Virtual Machine은 Java 애플리케이션에 대한 스케줄링 및 메모리 관리를 제공하는 다중 스레드 프로세스입니다. 멀티 코어 리소스의 직접적인 이점을 얻을 수 있는 Java 애플리케이션에는 Sun의 Java 애플리케이션 서버, BEA의 Weblogic, IBM의 Websphere 및 오픈 소스 Tomcat 애플리케이션 서버와 같은 애플리케이션 서버가 포함됩니다. Java 2 Platform, Enterprise Edition(2EE Platform) 애플리케이션 서버를 사용하는 모든 애플리케이션은 멀티 코어 기술의 이점을 즉시 누릴 수 있습니다.

  • 다중 인스턴스 애플리케이션: 단일 애플리케이션이 많은 수의 스레드를 활용하도록 확장할 수 없는 경우에도 애플리케이션의 여러 인스턴스를 병렬로 실행하여 다중 코어 아키텍처의 이점을 누릴 수 있습니다. 여러 애플리케이션 인스턴스에 어느 정도 격리가 필요한 경우 가상화 기술(운영 체제 하드웨어용)을 사용하여 각 애플리케이션 인스턴스에 고유한 독립적인 보안 환경을 제공할 수 있습니다.

다음 그림은 게임 엔진 Source의 렌더링 모듈의 스레드 구조를 보여줍니다. 이 계층 구조에서 상위 수준 스레드는 필요에 따라 하위 수준 스레드를 생성합니다. 렌더링 모듈은 소스 엔진의 핵심 부분인 게임 세계의 시각적 요소에 대한 데이터베이스 표현인 세계 목록에 의존합니다. 첫 번째 작업은 렌더링해야 하는 세계의 영역을 결정하는 것이고, 다음은 여러 각도에서 볼 때 장면의 개체를 결정하는 것이며, 프로세서 집약적인 작업이 수행됩니다. 렌더링 모듈은 다양한 관점(예: 플레이어의 관점, 텔레비전 모니터의 관점, 물에 반사되는 관점)에서 각 개체를 렌더링해야 합니다.

18.13 안드로이드

Android는 모바일 장치에서 실행되도록 설계된 비교적 새로운 운영 체제입니다. Android는 Linux 커널을 기반으로 하며 대부분의 Linux 기능(프로세스, 사용자 ID, 가상 메모리, 파일 시스템, 스케줄링 등)을 사용하여 Linux 커널 자체에 몇 가지 새로운 개념을 도입하며 때로는 원래 의도했던 것과 완전히 다른 방식으로 도입합니다.

Android는 출시 이후 가장 널리 사용되는 스마트폰 운영 체제 중 하나가 되었습니다. 그 인기로 인해 스마트폰이 폭발적으로 증가했으며, 모바일 장치 제조업체는 이를 자사 제품에 무료로 사용할 수 있습니다. 또한 오픈 소스 플랫폼이므로 다양한 장치에 맞게 사용자 정의할 수 있습니다. 타사 애플리케이션 생태계가 유리한 태블릿, TV, 게임 시스템, 미디어 플레이어와 같은 소비자 중심 장치에서 인기가 있을 뿐만 아니라 VOIP 전화, 스마트 시계, 자동차 대시보드, 의료 기기 및 가전 제품과 같이 그래픽 사용자 인터페이스(GUI)가 필요한 특수 장치의 임베디드 운영 체제로 점점 더 많이 사용되고 있습니다.

Android 운영체제의 상당 부분은 고급 언어(Java 프로그래밍 언어)로 작성되었습니다. 커널과 수많은 저수준 라이브러리는 C와 C++로 작성되었습니다. 그러나 대부분의 시스템은 Java로 작성되었으며 일부 사소한 예외를 제외하고 전체 애플리케이션 API도 Java로 작성 및 릴리스되었습니다. Java로 작성된 Android의 일부는 매우 객체 지향적인 디자인을 따르는 경향이 있으며, 이는 언어가 권장하는 것입니다.

18.13.1 안드로이드와 구글

Android는 오픈 소스 코드와 폐쇄 소스 타사 애플리케이션을 결합한 특이한 운영 체제입니다. Android 오픈 소스 프로젝트(AOSP)라고 불리는 Android의 오픈 소스 부분은 완전히 공개되어 누구나 자유롭게 사용하고 수정할 수 있습니다.

Android의 중요한 목표는 애플리케이션을 실행하기 위해 안정적인 구현과 API가 필요한 풍부한 타사 애플리케이션 환경을 지원하는 것입니다. 그러나 각 장치 제조업체가 원하는 대로 플랫폼을 맞춤 설정할 수 있는 오픈 소스 세계에서는 호환성 문제가 빠르게 발생할 수 있습니다. 이 갈등을 통제할 수 있는 방법이 필요합니다.

Android용 솔루션의 일부는 Android가 타사 애플리케이션과 호환되어야 하는 방법을 설명하는 CDD(호환성 정의 문서)입니다. 문서 자체에는 호환되는 Android 장치에 대한 요구 사항이 설명되어 있습니다. 그러나 이러한 호환성을 강제할 수 있는 방법이 없으면 무시되는 경향이 있으며 이를 달성하려면 몇 가지 추가 메커니즘이 필요합니다.

Android는 오픈 소스 플랫폼 위에 추가 독점 서비스를 생성하여 플랫폼 자체에서는 불가능한 서비스(일반적으로 클라우드 기반)를 제공함으로써 이 문제를 해결합니다. 이러한 서비스는 독점적이므로 해당 서비스를 포함할 수 있는 장치를 제한할 수 있으므로 이러한 장치에 대한 CDD 호환성이 필요합니다.

Google은 다양한 독점 클라우드 서비스를 지원하기 위해 Android를 구현했습니다. 그 중 Gmail, 캘린더 및 연락처 동기화, 클라우드-기기 메시징 등 일부는 사용자에게 표시되고 일부는 표시되지 않는 Google의 서비스 제품군이 대표적인 예입니다. 호환되는 앱을 제공할 때 가장 중요한 서비스는 Google Play입니다.

Google Play는 Android 앱을 위한 Google의 온라인 스토어입니다. 일반적으로 개발자는 Android 앱을 만들 때 Google Play를 사용하여 게시합니다. Google Play(또는 기타 앱 스토어)는 앱이 Android 기기에 전달되는 채널이므로 이 독점 서비스는 앱이 전달되는 기기에서 실행되도록 하는 역할을 담당합니다.

Google Play는 호환성을 보장하기 위해 두 가지 주요 메커니즘을 사용합니다. 첫 번째이자 가장 중요한 요구 사항은 함께 제공되는 모든 장치가 CDD에 따라 호환되는 Android 장치여야 하며 모든 장치의 기본 동작을 보장해야 한다는 것입니다. 또한 Google Play는 앱에 필요한 기기의 모든 기능(예: 지도 탐색을 수행하기 위한 GPS 보유)을 인식해야 하므로 이러한 기능이 없는 기기에서는 앱을 사용할 수 없습니다.

18.13.2 안드로이드 기록

구글은 2000년대 중반 안드로이드 개발 초기에 스타트업인 안드로이드를 인수하면서 안드로이드를 개발했다. 오늘날 존재하는 Android 플랫폼의 거의 모든 개발은 Google의 관리하에 이루어졌습니다.

18.13.2.1 초기 개발

Android Inc.는 보다 스마트한 모바일 장치를 만들기 위한 소프트웨어를 개발하기 위해 설립된 소프트웨어 회사입니다. 처음에는 카메라를 겨냥했지만 잠재 시장이 더 크기 때문에 관심이 빠르게 스마트폰으로 옮겨졌습니다. 원래 목표는 당시 모바일 기기 개발의 어려움을 리눅스 위에 널리 사용할 수 있는 개방형 플랫폼을 구축해 해결하는 것이었습니다.

이 기간 동안 플랫폼의 사용자 인터페이스 프로토타입이 구현되어 그 뒤에 있는 아이디어를 보여주었습니다. 풍부한 애플리케이션 개발 환경을 지원하기 위해 플랫폼 자체는 JavaScript, Java 및 C++의 세 가지 주요 언어를 대상으로 합니다.

Google은 2005년 7월 Android를 인수하여 Android를 완전한 제품으로 계속 개발하는 데 필요한 리소스와 클라우드 서비스 지원을 제공했습니다. 이 기간 동안 소규모 엔지니어 그룹이 긴밀하게 협력하여 플랫폼의 핵심 인프라 개발을 시작하고 더 높은 수준의 애플리케이션 개발을 위한 기반을 마련했습니다.

2006년 초에 계획이 크게 변경되었습니다. 즉, 플랫폼은 여러 프로그래밍 언어를 지원하는 대신 애플리케이션 개발을 위해 Java 프로그래밍 언어에만 전적으로 초점을 맞추게 되었습니다. 표면적으로는 원래의 다국어 접근 방식이 모든 사람을 "세계 최고"에 만족시켰기 때문에 이것은 어려운 변화였습니다. 다른 언어를 선호하는 엔지니어들에게 한 가지 언어에 집중하는 것은 한 발 뒤로 물러나는 것처럼 느껴졌습니다.

하지만 모두를 행복하게 만들려고 노력하면 누구나 쉽게 행복해질 수 있습니다. 세 가지 다른 언어 API 세트를 구축하려면 하나의 언어에 집중하는 것보다 더 많은 노력이 필요하므로 각 언어의 품질이 크게 저하됩니다. Java 언어에 집중하기로 한 결정은 플랫폼의 궁극적인 품질과 중요한 기한을 맞추는 개발 팀의 능력에 매우 중요했습니다.

개발이 진행됨에 따라 Android 플랫폼은 최종적으로 출시될 애플리케이션과 밀접하게 연결됩니다. Google은 이미 Gmail, 지도, 캘린더, YouTube를 비롯한 다양한 서비스를 제공하고 있으며 Android에서 사용할 수 있는 검색도 제공합니다. 이전 플랫폼에서 이러한 애플리케이션을 구현하면서 얻은 지식은 다시 설계에 반영되었습니다. 애플리케이션에 대한 이러한 반복적인 프로세스를 통해 플랫폼의 많은 설계 결함을 개발 초기에 해결할 수 있습니다.

대부분의 초기 애플리케이션 개발은 개발자가 사용할 수 있는 기본 플랫폼이 거의 없는 상태에서 수행되었습니다. 플랫폼은 일반적으로 모든 시스템과 애플리케이션이 호스트 컴퓨터에서 하나의 프로세스로 실행될 수 있도록 하는 "에뮬레이터"를 통해 프로세스 내에서 실행됩니다. 실제로 오늘날에도 애플리케이션과 같은 이전 구현의 일부 잔재가 남아 있습니다. onTerminate 메소드는 SDK(Software Development Kit)에 여전히 존재하며 Android 프로그래머가 애플리케이션을 작성하는 데 사용됩니다.

2006년 6월, 예정된 제품의 소프트웨어 개발 대상으로 하드웨어 장치 2개가 선정되었습니다. 코드명 "Sooner"인 첫 번째 제품은 기존 스마트폰을 기반으로 하며 터치 입력이 필요 없는 QWERTY 키보드와 화면을 갖추고 있습니다. 장치의 목표는 기존 하드웨어를 활용하여 가능한 한 빨리 초기 제품을 출시하는 것입니다. 코드명 "Dream"이라는 두 번째 대상 장치는 Android용으로 특별히 설계되었으며 의도한 대로 정확하게 작동합니다. 여기에는 대형(당시) 터치 스크린, 슬라이드형 QWERTY 키보드, 3G 라디오(빠른 웹 검색용), 가속도계, GPS 및 나침반(Google 지도 지원용) 등이 포함되었습니다.

소프트웨어 일정이 명확해지면서 두 가지 하드웨어 일정이 타당하지 않다는 것이 분명해졌습니다. Sooner가 출시될 무렵 하드웨어는 이미 구식이었고 Sooner의 노력으로 더 중요한 Dream 장치가 출시되었습니다. 이 문제를 해결하기 위해 목표 장치인 Sooner를 포기하고(이 하드웨어의 개발은 새 하드웨어가 준비될 때까지 한동안 계속되었지만) 전적으로 Dream에 집중하기로 결정했습니다.

18.13.2.2 안드로이드 1.0

Android 플랫폼의 첫 번째 공개 릴리스는 2007년 11월에 출시된 Preview SDK였습니다. 여기에는 하드웨어 장치 에뮬레이터, API 문서, 완전한 Android 장치 시스템 이미지와 핵심 애플리케이션을 실행하는 개발 환경이 포함되었습니다. 이 시점에서 핵심 디자인과 구현이 준비되었으며 대부분의 측면에서 우리가 논의할 최신 Android 시스템 아키텍처와 매우 유사합니다. 이번 발표에는 Sooner 및 Dream 하드웨어에서 실행되는 플랫폼에 대한 비디오 데모가 포함되어 있습니다.

Android의 초기 개발은 진행 중인 프로세스를 추진하고 시연하기 위해 일련의 분기별 데모 마일스톤에 따라 수행됩니다. SDK 릴리스는 플랫폼의 첫 번째 공식 버전입니다. 이를 위해서는 애플리케이션 개발을 위해 지금까지 모아진 모든 부분을 모아 정리하고 문서화하고 타사 개발자를 위한 응집력 있는 개발 환경을 조성해야 합니다.

이제 개발은 두 가지 트랙에 따라 진행됩니다. 하나는 SDK에 대한 피드백을 받아 API를 더욱 구체화하고 마무리하는 것이고, 다른 하나는 Dream 장치 제공에 필요한 구현을 완료하고 안정화하는 것입니다. 이 기간 동안 SDK는 여러 차례 공개 업데이트를 거쳐 2008년 8월 거의 최종 API가 포함된 버전 0.9가 출시되었습니다.

플랫폼 자체는 급속도로 성장해 왔으며, 2008년 봄에는 그 꿈이 실현될 수 있도록 안정화에 초점을 맞췄습니다. 현재 Android에는 C 라이브러리의 일부부터 Dalvik 인터프리터(애플리케이션을 실행하는), 시스템, 애플리케이션에 이르기까지 상용 제품으로 출시된 적이 없는 대량의 코드가 포함되어 있습니다.

Android에는 이전에 볼 수 없었던 몇 가지 참신한 디자인 아이디어도 포함되어 있으며 어떻게 구현될지는 불분명합니다. 이 모든 것이 안정적인 제품으로 결합되어야 했고, 팀은 이 모든 것이 실제로 결합되어 예상대로 작동하는지 궁금해하는 데 몇 달을 보냈습니다.

마침내 2008년 8월에 소프트웨어가 안정적으로 출시될 준비가 되었습니다. 제품이 공장에 출고되고 장비에서 깜박이기 시작합니다. 9월에는 현재 T-Mobile G1으로 불리는 Dream 기기에 Android 1.0이 출시되었습니다.

18.13.2.3 후속 개발

Android 1.0 출시 이후 개발은 빠르게 진행되었습니다. 향후 5년 동안 플랫폼은 초기 1.0 버전에 비해 다양한 새로운 기능과 개선 사항을 추가하는 약 15번의 주요 업데이트를 받았습니다.

원본 호환성 정의 파일은 기본적으로 T-Mobile G1과 매우 유사한 호환 장치만 허용했습니다. 향후 몇 년 동안 호환되는 장치의 범위가 크게 확장될 것입니다. 이 프로세스의 핵심 사항은 다음과 같습니다.

  • 2009년에 Android 버전 1.5부터 2.0은 물리적 키보드에 대한 요구 사항을 없애기 위한 소프트 키보드, 낮은 QVGA 장치뿐만 아니라 WVGA Motorola Droid와 같은 더 크고 더 높은 밀도의 새로운 장치에 대한 더 넓은 화면 지원(크기 및 픽셀 밀도), 장치가 지원하는 하드웨어 기능을 보고하는 새로운 "시스템 기능" 기능, 필요한 하드웨어 기능을 나타내는 애플리케이션을 도입했습니다. 후자는 특정 기기와의 앱 호환성을 확인하기 위해 Google Play에서 사용하는 주요 메커니즘입니다.

  • 2011년 Android 버전 3.0~4.0에서는 플랫폼에 10인치 이상의 태블릿에 대한 새로운 핵심 지원이 도입되었습니다. 이제 핵심 플랫폼은 소형 QVGA 휴대폰부터 스마트폰, 대형 "태블릿", 7인치 태블릿, 대형 태블릿, 10인치 이상까지 장치 화면 크기를 완벽하게 지원합니다.

  • 플랫폼이 더 큰 화면뿐만 아니라 마우스 유무에 관계없이 비터치 장치를 지원하는 다양한 하드웨어에 대한 기본 지원을 제공하면서 더 다양한 Android 장치가 등장했습니다. 여기에는 Google TV, 게임 장치, 노트북, 카메라 및 기타 TV 장치가 포함됩니다.

또한 중요한 개발 작업은 덜 명확한 영역, 즉 Android 오픈 소스 플랫폼에서 Google의 독점 서비스를 보다 명확하게 분리하는 작업으로 진행되었습니다.

Android 1.0의 경우 깔끔한 타사 애플리케이션 API와 독점 Google 코드에 의존하지 않는 오픈 소스 플랫폼을 만드는 데 많은 작업이 이루어졌습니다. 그러나 Google의 독점 코드 구현은 아직 정리되지 않은 경우가 많으며 Google의 독점 코드와 잘 통합하기 위해 필요한 기능조차 없는 플랫폼의 내부 부분에 의존하는 경우가 많습니다. 이러한 문제를 해결하기 위해 일련의 프로젝트가 신속하게 시작되었습니다.

  • 2009년 Android 버전 2.0에서는 제3자가 자체 동기화 어댑터를 연락처 데이터베이스와 같은 플랫폼 API에 연결할 수 있는 아키텍처를 도입했습니다. 다양한 데이터를 동기화하기 위한 Google의 코드가 잘 정의된 SDK API로 이동되었습니다.

  • 2010년 Android 버전 2.2에는 Google 독점 코드에 대한 내부 설계 및 구현 작업이 포함되었습니다. 이러한 '대단한 분리'는 클라우드 기반 시스템 소프트웨어 업데이트 제공부터 '클라우드-기기 메시징' 및 기타 백그라운드 서비스에 이르기까지 많은 핵심 Google 서비스를 플랫폼과 별도로 제공 및 업데이트할 수 있도록 깔끔하게 구현합니다.

  • 2012년에는 Google의 독점 비앱 서비스에 대한 업데이트와 새로운 기능이 포함된 새로운 Google Play 서비스 앱이 기기에 제공되었습니다. 이는 Google이 클라우드-기기 메시징 및 매핑과 같은 독점 API를 완전히 제공하고 업데이트할 수 있도록 한 2010년 분사 노력의 결과입니다.

18.13.3 Android 디자인 목표

Android 플랫폼을 개발하는 동안 여러 가지 주요 설계 목표가 나타났습니다.

  1. 모바일 장치를 위한 완전한 오픈 소스 플랫폼을 제공합니다. Android의 오픈 소스 부분은 다양한 애플리케이션을 포함하고 완전한 제품으로 출시될 수 있는 상향식 운영 체제 스택입니다.

  2. 강력하고 안정적인 API를 통해 독점적인 타사 애플리케이션을 강력하게 지원합니다. 앞서 언급했듯이 진정한 오픈 소스이자 독점 타사 애플리케이션에서 사용할 수 있을 만큼 안정적인 플랫폼을 유지하는 것은 어려운 일입니다. Android는 이 문제를 해결하기 위해 기술 솔루션(잘 정의된 SDK 지정 및 공개 API와 내부 구현 간의 구분 지정)과 정책 요구사항(CDD를 통해)을 혼합하여 사용합니다.

  3. Google 앱을 포함한 모든 타사 앱이 공평한 경쟁의 장에서 경쟁할 수 있도록 허용합니다. Android 오픈 소스 코드는 클라우드 서비스(예: 데이터 동기화 또는 클라우드-기기 메시징 API)부터 라이브러리(예: Google의 매핑 라이브러리) 및 App Store와 같은 풍부한 서비스에 액세스하는 등 그 위에 구축된 높은 수준의 시스템 기능에서 최대한 중립적으로 설계되었습니다.

  4. 사용자가 타사 애플리케이션을 깊이 신뢰할 필요가 없는 애플리케이션 보안 모델을 제공합니다. 운영 체제는 충돌을 일으킬 수 있는 버그가 있는 앱뿐만 아니라 장치 및 장치의 사용자 데이터에 대한 보다 미묘한 오용으로부터 사용자를 앱 오작동으로부터 보호해야 합니다. 사용자가 애플리케이션을 신뢰할 필요가 적을수록 애플리케이션을 시도하고 설치하는 데 더 많은 자유가 주어집니다.

  5. 일반적인 모바일 사용자 상호 작용을 지원합니다. 많은 애플리케이션에서 짧은 시간을 소비합니다. 모바일 경험은 앱과의 간단한 상호 작용을 포함하는 경향이 있습니다. 새로 받은 이메일 검색, SMS 또는 인스턴트 메시지 수신 및 보내기, 연락처에 연락하여 전화 걸기 등. Android는 일반적으로 기본 앱을 콜드 스타트하는 데 200밀리초를 목표로 합니다.

  6. 사용자를 위한 신청 프로세스를 관리하고, 사용자가 신청을 완료할 때 신청을 닫는 것에 대해 걱정할 필요가 없도록 신청의 사용자 경험을 단순화합니다. 또한 모바일 장치는 스왑 공간 없이 실행되는 경향이 있습니다. 이는 현재 실행 중인 응용 프로그램 세트에 실제로 사용 가능한 것보다 더 많은 RAM이 필요할 때 운영 체제가 더 정상적으로 실패할 수 있도록 해줍니다. 이러한 두 가지 요구 사항을 충족하려면 시스템은 프로세스를 관리하고 시작 및 중지 시기를 결정하는 데 보다 적극적인 접근 방식을 취해야 합니다.

  7. 풍부하고 안전한 방식으로 애플리케이션이 상호 운용되고 협업하도록 장려합니다. 어떤 면에서 모바일 앱은 셸 명령에 대한 후퇴입니다. 모바일 앱은 점점 더 큰 데스크톱 앱을 위한 모놀리식 디자인이 아니라 특정 요구에 맞게 설계되었습니다. 이를 지원하기 위해 운영 체제는 이러한 응용 프로그램이 함께 작동하여 더 큰 전체를 만들 수 있도록 새로운 유형의 기능을 제공해야 합니다.

  8. 완전한 범용 운영 체제를 만듭니다. 모바일 장치는 기존 데스크톱 운영 체제보다 단순한 범용 컴퓨팅의 새로운 표현입니다. Android의 디자인은 최소한 기존 운영 체제만큼의 성능을 발휘할 수 있을 만큼 풍부해야 합니다.

18.13.4 안드로이드 아키텍처

Android는 표준 Linux 커널을 기반으로 구축되었으며 커널 자체에 대한 몇 가지 중요한 확장만 포함되어 있습니다. 그러나 일단 사용자 공간에서 구현되면 기존 Linux 배포판과 매우 다르며 많은 Linux 기능을 매우 다른 방식으로 사용합니다.

기존 Linux 시스템과 마찬가지로 Android의 첫 번째 사용자 공간 프로세스는 다른 모든 프로세스의 루트인 init입니다. 그러나 데몬 Android의 init 프로세스는 크론 작업 예약과 같은 고급 사용자 기능보다는 낮은 수준의 세부 정보(파일 시스템 및 하드웨어 액세스 관리)에 더 중점을 두어 다르게 시작됩니다. 또한 Android에는 Dalvik의 Java 언어 환경을 실행하고 Java로 구현된 시스템의 모든 부분을 실행하는 추가 프로세스 계층이 있습니다.

아래 그림은 안드로이드의 기본 프로세스 구조를 보여준다. 첫 번째는 많은 저수준 데몬 프로세스를 생성하는 init 프로세스입니다. 그 중 하나가 고급 Java 언어 프로세스의 루트인 zygote입니다.

Android 프로세스 계층 구조.

일반적인 Android 기기에는 셸 액세스를 위한 로컬 콘솔이 없기 때문에 Android의 init는 기존 방식으로 셸을 실행하지 않습니다. 대신 데몬 adbd는 셸 액세스(예: USB를 통해)를 요청하는 원격 연결을 수신하여 필요에 따라 셸 프로세스를 분기합니다.

대부분의 Android는 Java로 작성되므로 zygote 데몬과 이 데몬이 시작하는 프로세스가 시스템의 핵심입니다. 항상 시작되는 첫 번째 프로세스는 모든 핵심 운영 체제 서비스를 포함하는 시스템 서버라고 하며, 주요 서비스로는 전원 관리자, 패키지 관리자, 창 관리자 및 활동 관리자가 있습니다.

필요에 따라 다른 프로세스가 접합체에서 생성됩니다. 이들 중 일부는 전화 프로세스의 전화 스택과 같이 기본 운영 체제의 일부이며 항상 실행되어야 하는 "지속적" 프로세스입니다. 시스템이 실행되는 동안 필요에 따라 다른 응용 프로그램 프로세스가 생성되고 중지됩니다.

애플리케이션은 운영 체제에서 제공하는 라이브러리를 호출하여 운영 체제와 상호 작용합니다. 이러한 라이브러리는 함께 Android 프레임워크를 구성합니다. 이러한 라이브러리 중 일부는 해당 프로세스 내에서 작업을 수행할 수 있지만 대부분은 다른 프로세스(일반적으로 시스템 서버 프로세스 내의 서비스)와의 프로세스 간 통신을 수행해야 합니다.

아래 다이어그램은 시스템 서비스(이 경우 패키지 관리자)와 상호작용하는 Android 프레임워크 API의 일반적인 디자인을 보여줍니다. 패키지 관리자는 애플리케이션이 로컬 프로세스에서 호출할 수 있는 프레임워크 API를 제공합니다. 여기에는 PackageManager 클래스가 있습니다. 내부적으로 이 클래스는 시스템 서버의 해당 서비스에 대한 연결을 얻어야 합니다. 이를 달성하기 위해 시작 시 시스템 서버는 서비스 관리자(init에 의해 시작된 데몬)에 잘 정의된 이름으로 각 서비스를 게시합니다. 애플리케이션 프로세스의 PackageManager는 동일한 이름을 사용하여 서비스 관리자에서 시스템 서비스로의 연결을 검색합니다.

시스템 서비스를 게시하고 상호 작용합니다.

PackageManager가 시스템 서비스에 연결되면 이를 호출할 수 있습니다. PackageManager에 대한 대부분의 애플리케이션 호출은 Android의 Binder IPC 메커니즘을 사용하여 프로세스 간 통신으로 구현됩니다. 이 경우 시스템 서버에서 PackageManagerService 구현을 호출합니다. PackageManagerService의 구현은 모든 클라이언트 응용 프로그램 간의 상호 작용을 중재하고 여러 응용 프로그램에 필요한 상태를 유지 관리합니다.

18.13.5 리눅스 확장

대부분의 경우 Android에는 표준 Linux 기능을 제공하는 Linux 커널이 포함되어 있습니다. 운영 체제로서 Android의 가장 흥미로운 측면은 기존 Linux 기능을 사용하는 방식입니다. 그러나 Android 시스템은 Linux의 몇 가지 중요한 확장 기능에도 의존합니다.

18.13.5.1 웨이크 잠금

모바일 장치의 전원 관리는 기존 컴퓨팅 시스템과 다르기 때문에 Android는 시스템이 절전 모드로 전환되는 방식을 관리하는 wake lock(일시 중단 차단이라고도 함)이라는 새로운 기능을 Linux에 추가했습니다.

기존 컴퓨팅 시스템에서 시스템은 두 가지 전원 상태 중 하나일 수 있습니다. 실행 중이며 사용자 입력을 받을 준비가 되어 있거나, 외부 인터럽트(예: 전원 키 누르기) 없이 실행을 계속할 수 없는 최대 절전 상태입니다. 실행 중에 필요에 따라 보조 하드웨어를 켜거나 끌 수 있지만, 들어오는 네트워크 트래픽 및 기타 이벤트를 처리하려면 CPU 자체와 하드웨어의 핵심 부분에 전원이 계속 공급되어야 합니다. 저전력 절전 상태로 들어가는 경우는 비교적 드물게 발생합니다. 즉, 사용자가 명시적으로 시스템을 절전 모드로 전환하거나 오랫동안 사용자가 활동하지 않는 경우입니다. 이 절전 상태를 종료하려면 키보드의 버튼을 누르는 등 외부 소스의 하드웨어 인터럽트가 필요합니다. 이때 장치가 깨어나고 화면이 켜집니다.

모바일 장치 사용자의 기대치는 다릅니다. 사용자는 장치를 절전 모드로 전환하는 것처럼 보이는 방식으로 화면을 끌 수 있지만 기존 절전 상태는 실제로 이상적이지 않습니다. 기기는 화면이 꺼진 상태에서도 작동할 수 있어야 합니다. 즉, 전화를 받을 수 있어야 하고, 채팅 메시지에 대한 데이터를 받고 처리할 수 있어야 하며, 기타 여러 가지 기능이 있어야 합니다.

기존 컴퓨터에 비해 사람들은 모바일 장치 화면을 켜고 끄는 데 더 높은 요구 사항을 가지고 있습니다. 모바일 상호작용은 하루 종일 여러 짧은 순간에 발생하는 경향이 있습니다. 메시지를 받고, 기기를 열어 확인하고, 한 문장으로 답장을 보내고, 개와 산책하는 친구를 만나 기기를 열어 사진을 찍습니다. 이러한 일반적인 모바일 사용에서 장치를 꺼낸 후 사용할 준비가 될 때까지의 지연은 사용자 경험에 상당히 부정적인 영향을 미칠 수 있습니다.

이러한 요구 사항을 고려할 때 한 가지 해결책은 장치의 화면이 꺼져 있을 때 언제든지 다시 켤 수 있도록 CPU를 절전 모드로 전환하지 않는 것입니다. 결국, 커널은 어떤 스레드에 대해 작업이 예약되지 않은 시기를 알고 있으며 Linux(및 대부분의 운영 체제)는 이 경우 더 적은 전력을 사용하여 자동으로 CPU를 유휴 상태로 만듭니다. 그러나 유휴 CPU는 실제 절전 모드와 동일하지 않습니다. 예를 들면:

  1. 많은 칩셋에서 유휴 상태는 실제 절전 상태보다 훨씬 더 많은 전력을 사용합니다.

  2. 어떤 작업을 사용할 수 있게 되면 작업이 중요하지 않더라도 유휴 CPU는 언제든지 깨어날 수 있습니다.

  3. 단지 CPU를 유휴 상태로 두는 것이 실제 절전 모드에서 필요하지 않은 다른 하드웨어를 끌 수 있다는 의미는 아닙니다.

Android의 Wakelock을 사용하면 화면 끄기와 같은 명시적인 사용자 작업 없이 시스템이 더 깊은 절전 모드로 들어갈 수 있습니다. wakelock이 있는 시스템의 기본 상태는 기기가 절전 모드인 것입니다. 장치가 실행되는 동안 장치가 다시 절전 모드로 전환되는 것을 방지하려면 wake lock을 유지해야 합니다.

시스템은 화면이 켜져 있을 때 항상 wakelock을 유지하여 기기가 절전 모드로 전환되는 것을 방지하므로 예상대로 계속 실행됩니다. 그러나 일반적으로 시스템 자체는 화면이 꺼져 있을 때 wake lock을 유지하지 않으므로 시스템은 다른 장치가 wake lock을 보유하고 있는 동안에만 잠자기 상태를 유지합니다. 더 이상 wake lock이 없으면 시스템은 절전 모드로 전환되며 하드웨어 인터럽트를 통해서만 절전 모드를 종료할 수 있습니다.

시스템이 절전 모드로 전환되면 하드웨어 인터럽트가 기존 운영 체제에서와 마찬가지로 시스템을 다시 깨웁니다. 이러한 중단의 원인으로는 시간 기반 경고, 셀룰러 라디오의 이벤트(예: 수신 통화), 수신 네트워크 트래픽, 특정 하드웨어 버튼(예: 전원 버튼) 누르기 등이 있습니다. 이러한 이벤트에 대한 인터럽트 핸들러에는 표준 Linux에서 한 가지 변경 사항이 필요합니다. 즉, 시스템에서 인터럽트를 처리한 후 시스템을 계속 실행하려면 초기 wake lock을 획득해야 합니다.

인터럽트 처리기에 의해 획득된 wake lock은 이벤트를 계속 처리할 커널의 드라이버에 스택 위로 제어권을 전달할 수 있을 만큼 오랫동안 유지되어야 합니다. 그런 다음 커널 드라이버는 자체 wake lock을 획득해야 하며, 그 후에는 시스템이 절전 모드로 돌아갈 위험 없이 인터럽트 wake lock을 안전하게 해제할 수 있습니다.

이후에 드라이버가 이 이벤트를 사용자 공간에 전달하려는 경우 유사한 핸드셰이크가 필요합니다. 드라이버는 이벤트가 대기 중인 사용자 프로세스에 전달될 때까지 wake lock을 계속 유지하고 자체 wake lock을 획득할 기회가 있는지 확인해야 합니다. 흐름은 사용자 공간의 하위 시스템에서도 계속될 수 있습니다. wake lock이 유지되는 한 이벤트에 대한 응답으로 필요한 처리를 계속 수행합니다. 그러나 wake lock이 더 이상 유지되지 않으면 전체 시스템이 절전 모드로 돌아가고 모든 처리가 중지됩니다.

18.13.5.2 메모리 부족 킬러

Linux에는 메모리가 매우 부족할 때 복구를 시도하는 Out-Of-Memory Killer가 포함되어 있습니다. 메모리가 부족한 최신 운영 체제의 상황은 모호합니다. 페이징 및 스와핑을 사용하면 애플리케이션 자체가 메모리 부족 오류로 인해 어려움을 겪는 경우가 거의 없습니다. 그러나 커널은 새로운 할당뿐만 아니라 현재 사용 중인 특정 주소 범위를 스와핑하거나 페이징할 때 필요할 때 여유 RAM 페이지를 찾을 수 없는 상황에 여전히 직면합니다.

메모리가 부족한 상황에서 표준 Linux 메모리 부족 킬러는 커널이 수행 중인 작업을 계속할 수 있도록 RAM을 찾는 최후의 수단입니다. 이는 각 프로세스에 "나쁜" 수준을 할당하고 최악으로 간주되는 프로세스를 종료함으로써 수행됩니다. 프로세스의 품질은 사용하는 RAM의 양, 실행하는 데 걸리는 시간 및 기타 요인에 따라 달라집니다. 목표는 중요하지 않은 대규모 프로세스를 종료하는 것입니다.

Android는 메모리 부족 킬러에게 특별한 부담을 줍니다. 스왑 공간이 없으므로 메모리가 부족한 상황에서 더 일반적입니다. 최근에 사용한 스토리지에서 매핑된 클린 RAM 페이지를 제거하는 것 외에는 메모리 부족을 완화할 수 있는 방법이 없습니다. 그럼에도 불구하고 Android는 표준 Linux 구성을 사용하여 메모리를 과도하게 커밋합니다. 즉, 이를 지원하는 데 사용 가능한 RAM이 있다는 보장 없이 RAM에 주소 공간을 할당할 수 있습니다. 오버커밋은 파일의 전체 데이터 중 작은 부분만 RAM에 로드하면 되는 대용량 파일(예: 실행 파일)을 mmap하는 데 자주 사용되기 때문에 메모리 사용량을 최적화하는 데 매우 중요한 도구입니다.

기존 Linux 메모리 부족 킬러는 최후의 수단에 가깝고 종료할 좋은 프로세스를 제대로 식별하기가 어렵기 때문에 이 경우 제대로 작동하지 않습니다. 실제로 Android는 프로세스를 가져오고 올바른 선택을 하기 위해 주기적으로 실행되는 메모리 부족 킬러에 크게 의존합니다.

이 문제를 해결하기 위해 Android는 다양한 의미와 디자인 목표를 가진 자체 메모리 부족 킬러를 커널에 도입했습니다. Android Out of Memory Killer는 더욱 적극적으로 실행됩니다. 즉, RAM이 "낮아" 질 때마다 낮은 RAM은 커널에서 허용되는 사용 가능한 여유 RAM 및 캐시 RAM의 양을 나타내는 조정 가능한 매개변수로 식별됩니다. 시스템이 해당 제한 아래로 떨어지면 Out of Memory Killer가 실행되어 다른 곳에서 RAM을 확보합니다. 목표는 시스템이 잘못된 페이징 상태에 빠지지 않도록 하는 것입니다. 이는 지속적인 페이징 입력 및 출력으로 인해 성능이 느려지므로 포그라운드 애플리케이션이 RAM을 놓고 경쟁할 때 사용자 경험에 부정적인 영향을 미칠 수 있습니다.

Android의 Out of Memory Killer는 어떤 프로세스를 종료해야 할지 추측하지 않고 사용자 공간에서 제공하는 정보에 매우 엄격하게 의존합니다. 전통적인 Linux 메모리 부족 킬러에는 프로세스의 전체 불량 점수를 수정하여 최적의 프로세스로 전환하는 데 사용할 수 있는 프로세스별 oom_adj 매개변수가 있습니다. Android의 메모리 부족 킬러는 동일한 매개변수를 사용하지만 엄격한 순서로 사용됩니다. oom_adj가 높은 프로세스는 항상 낮은 프로세스보다 먼저 종료됩니다.

18.13.6 달빅

Dalvik은 Android에서 Java 언어 환경을 구현하고 애플리케이션과 대부분의 시스템 코드 실행을 담당합니다. 패키지 관리자부터 창 관리자, 활동 관리자까지 거의 모든 시스템 서비스 프로세스는 Dalvik이 실행하는 Java 언어 코드로 구현됩니다.

그러나 Android는 전통적인 의미의 Java 언어 플랫폼이 아닙니다. Android 애플리케이션의 Java 코드는 Java의 기존 스택 기반 바이트코드가 아닌 레지스트리 기반의 Dalvik 바이트코드 형식으로 제공됩니다. Dalvik의 바이트코드 형식을 사용하면 JIT(Just-in-Time, Just-In-Time) 컴파일을 계속 지원하면서 더 빠른 해석이 가능합니다. Dalvik 바이트코드는 문자열 풀과 기타 기술을 사용하여 디스크와 RAM의 공간 효율성도 향상됩니다.

Android 애플리케이션을 작성할 때 소스 코드는 Java로 작성된 다음 기존 Java 도구를 사용하여 표준 Java 바이트코드로 컴파일됩니다. 그런 다음 Android에서는 Java 바이트코드를 Dalvik의 보다 간결한 바이트코드 표현으로 변환하는 새로운 단계를 도입합니다. 이는 애플리케이션의 Dalvik 바이트코드 버전으로, 최종 애플리케이션 바이너리로 패키징되어 최종적으로 장치에 설치됩니다.

Android의 시스템 아키텍처는 메모리 관리, 보안, 보안 경계를 넘는 통신을 포함하여 Linux 시스템 기본 요소에 크게 의존합니다. 이는 Java 언어를 핵심 운영 체제 개념으로 사용하지 않으며 기본 Linux 운영 체제의 이러한 중요한 측면을 추상화하려는 시도를 거의 하지 않습니다.

특히 주목할 점은 Android의 프로세스 사용입니다. Android의 디자인은 애플리케이션과 시스템 간의 격리를 달성하기 위해 Java 언어에 의존하지 않고 기존 운영 체제 프로세스 격리 방법을 사용합니다. 이는 각 애플리케이션이 자체 Linux 프로세스에서 실행되고 자체 Dalvik 환경이 있다는 것을 의미하며, 시스템 서버 및 Java로 작성된 플랫폼의 기타 핵심 부분도 마찬가지입니다.

이러한 종류의 격리를 위해 프로세스를 사용하면 Android는 메모리 격리부터 프로세스 종료 시 프로세스와 관련된 모든 리소스 정리에 이르기까지 프로세스 관리를 위해 Linux의 모든 기능을 활용할 수 있습니다. 프로세스를 제외하고 Android는 Java의 SecurityManager 아키텍처를 사용하는 대신 Linux의 보안 기능에 전적으로 의존합니다.

Linux 프로세스와 보안을 사용하면 Dalvik 환경이 크게 단순화됩니다. Dalvik은 더 이상 시스템 안정성과 견고성의 중요한 측면을 담당하지 않기 때문입니다. 불행하게도 이는 응용 프로그램이 구현 시 네이티브 코드를 자유롭게 사용할 수 있도록 허용하는데, 이는 일반적으로 C++ 기반 엔진을 사용하여 구축된 게임에 특히 중요합니다.

이와 같이 프로세스와 Java 언어를 혼합하면 몇 가지 문제가 발생합니다. 최신 모바일 하드웨어에서도 새로운 Java 로케일을 생성하는 데 1초가 걸립니다. Android의 설계 목표 중 하나는 200밀리초를 목표로 빠르게 애플리케이션을 실행할 수 있는 것이었습니다. 이 새로운 애플리케이션을 위해 새로운 Dalvik 프로세스를 시작하도록 요구하는 것은 예산을 훨씬 초과하는 것입니다. 새로운 Java 로케일을 초기화할 필요가 없더라도 모바일 하드웨어에서는 200밀리초의 시작을 달성하기가 어렵습니다.

이 문제에 대한 해결책은 앞서 간략하게 언급한 zygote 로컬 데몬입니다. Zygote는 Java로 작성된 시스템 또는 애플리케이션 코드 실행을 시작할 수 있을 때까지 Dalvik을 시작하고 초기화하는 역할을 담당합니다. 모든 새로운 Dalvik 기반 프로세스(시스템 또는 애플리케이션)는 Zygote에서 분기되어 이미 준비된 환경에서 실행을 시작할 수 있습니다.

Zygote는 Dalvik 이상의 기능을 제공하며, 시스템과 애플리케이션에서 일반적으로 사용되는 Android 프레임워크의 많은 부분을 사전 로드하고 리소스 및 기타 자주 필요한 항목을 로드합니다.

Zygote에서 새 프로세스를 생성하려면 Linux 포크가 필요하지만 exec 호출은 필요하지 않습니다. 새 프로세스는 모든 초기화 전 상태가 설정되어 사용할 준비가 된 원본 Zygote 프로세스의 복사본입니다. 아래 다이어그램은 새로운 Java 애플리케이션 프로세스가 원래 Zygote 프로세스와 어떻게 관련되는지 보여줍니다. 포크 후 새 프로세스는 자체적으로 독립적인 Dalvik 환경을 갖게 되지만 페이지에 복사본을 작성하여 Zygote와 미리 로드되고 초기화된 모든 데이터를 공유합니다. 이제 남은 것은 새로 실행되는 프로세스를 준비하고, 올바른 ID(UID 등)를 제공하고, 스레드를 시작하는 데 필요한 Dalvik 초기화를 완료하고, 실행할 애플리케이션 또는 시스템 코드를 로드하는 것입니다.

발사 속도 외에도 Zygote는 또 다른 이점을 제공합니다. 프로세스를 생성하는 데 사용되는 포크는 하나만 있기 때문에 Dalvik을 초기화하고 클래스와 리소스를 미리 로드하는 데 필요한 많은 수의 더티 RAM 페이지를 Zygote와 모든 하위 프로세스 간에 공유할 수 있습니다. 이러한 공유는 교환이 불가능한 Android 환경에서 특히 중요합니다. 실행 코드와 같은 페이지는 "디스크"(플래시 메모리)에서 페이징되어 필요할 때 정리될 수 있습니다. 그러나 더티 페이지는 RAM에 잠겨 있어야 하며 "디스크"로 페이지 아웃될 수 없습니다.

18.13.7 바인더 IPC

Android의 시스템 설계는 애플리케이션 간의 프로세스 격리와 시스템 자체의 여러 부분 간의 프로세스 격리를 중심으로 이루어집니다. 이를 위해서는 서로 다른 프로세스 간의 관계를 조정하기 위해 많은 프로세스 간 통신이 필요하며 올바르게 구현하고 처리하려면 많은 작업이 필요할 수 있습니다. Android의 Binder 프로세스 간 통신 메커니즘은 풍부한 범용 IPC 도구이며 대부분의 Android 시스템은 이를 기반으로 구축됩니다.

바인더 아키텍처는 아래 그림과 같이 세 가지 계층으로 구분됩니다. 스택 맨 아래에는 실제 프로세스 간 상호 작용을 구현하고 이를 커널 드라이버 및 모듈에 사용자 정의 명령을 보내는 데 사용되는 범용 커널 호출인 커널의 ioctl 함수를 통해 노출하는 커널 모듈이 있습니다. 커널 모듈 위에는 응용 프로그램이 IBinder 및 Binder 클래스를 통해 IPC 끝점을 생성하고 상호 작용할 수 있도록 하는 기본 객체 지향 사용자 공간 API가 있습니다. 맨 위에는 애플리케이션이 내부적으로 IPC가 어떻게 발생하는지에 대한 세부 사항에 대해 걱정하지 않고 IPC 인터페이스를 선언하는 인터페이스 기반 프로그래밍 모델이 있습니다.

바인더 IPC 아키텍처.

18.13.7.1 바인더 커널 모듈

파이프와 같은 기존 Linux IPC 도구를 사용하는 대신 Binder에는 자체 IPC 메커니즘을 구현하는 특수 커널 모듈이 포함되어 있습니다. BinderIPC 모델은 기존 Linux 메커니즘과 크게 다르기 때문에 사용자 공간에서 효율적으로 구현할 수 없습니다. 또한 Android는 오류가 있거나 악의적인 애플리케이션에서 리소스를 지우는 강력한 의미 체계를 제공하지 않기 때문에 크로스 프로세스 상호 작용(세마포어, 공유 메모리 세그먼트, 메시지 대기열)을 위한 대부분의 System V 기본 요소를 지원하지 않습니다.

Binder가 사용하는 기본 IPC 모델은 RPC(Remote Procedure Call)입니다. 즉, 송신 프로세스는 수신 프로세스에서 실행되는 완전한 IPC 작업을 커널에 제출합니다. 발신자는 수신자가 실행하는 동안 차단할 수 있으므로 호출에서 결과가 반환될 수 있습니다. (발신자는 선택적으로 차단하지 않고 수신자와 병렬로 실행을 계속하도록 지정할 수 있습니다.) 따라서 바인딩된 IPC는 Linux 파이프의 스트림 기반이 아니라 System V 메시지 대기열과 같은 메시지 기반입니다. 바인더의 메시지를 트랜잭션이라고 하며 더 높은 수준에서 프로세스 간 함수 호출로 볼 수 있습니다.

사용자 공간이 커널에 제출한 각 트랜잭션은 완전한 작업입니다. 작업 대상, 보낸 사람의 신원 및 전송되는 전체 데이터를 식별합니다. 커널은 트랜잭션을 수신할 적절한 프로세스를 결정하고 이를 프로세스의 대기 스레드에 전달합니다.

아래 다이어그램은 트랜잭션의 기본 흐름을 보여줍니다. 시작 프로세스의 모든 스레드는 대상을 식별하는 트랜잭션을 생성하고 이를 커널에 제출할 수 있습니다. 커널은 트랜잭션의 복사본을 생성하고 여기에 보낸 사람의 신원을 추가합니다. 트랜잭션의 대상을 담당하는 프로세스를 결정하고 이를 수신하기 위해 프로세스의 스레드를 깨웁니다. 수신 프로세스가 실행되면 트랜잭션의 적절한 대상을 결정하고 전달합니다.

기본적으로 IPC 트랜잭션을 바인딩합니다.

이 논의의 목적을 위해 트랜잭션 데이터가 시스템에서 두 개의 복사본(하나는 커널로, 다른 하나는 수신 프로세스의 주소 공간)으로 이동하는 방식을 단순화합니다. 실제 구현은 복사본으로 수행됩니다. 트랜잭션을 수신할 수 있는 각 프로세스에 대해 커널은 공유 메모리 영역을 만듭니다. 트랜잭션을 처리할 때 먼저 트랜잭션을 수신할 프로세스를 결정하고 해당 공유 주소 공간에 데이터를 직접 복사합니다.

위 다이어그램의 각 프로세스에는 들어오는 트랜잭션을 처리하기 위해 사용자 공간에서 생성된 하나 이상의 스레드인 "스레드 풀"이 있습니다. 커널은 들어오는 각 트랜잭션을 현재 프로세스의 스레드 풀에서 작업을 기다리고 있는 스레드로 전달합니다. 그러나 보내는 프로세스에서 커널을 호출하는 것은 스레드 풀에서 올 필요가 없으며 그림에 표시된 것처럼 프로세스의 모든 스레드가 자유롭게 트랜잭션을 시작할 수 있습니다.

우리는 커널에 대한 트랜잭션이 대상 객체를 식별한다는 것을 확인했습니다. 그러나 커널은 수신 프로세스를 식별해야 합니다. 이를 달성하기 위해 커널은 아래 그림과 같이 각 프로세스에서 사용 가능한 개체를 추적하고 이를 다른 프로세스에 매핑합니다. 여기에 표시된 개체는 단순히 해당 프로세스의 주소 공간에 있는 개체의 위치입니다. 커널은 이러한 객체 주소만 추적하며 아무 의미도 없습니다. 이는 C 데이터 구조, C++ 개체 또는 프로세스의 주소 공간에 있는 기타 개체의 위치일 수 있습니다.

원격 프로세스의 개체에 대한 참조는 Linux 파일 설명자와 마찬가지로 정수 핸들로 식별됩니다. 예를 들어 프로세스 2의 Object2a를 고려하면 커널은 프로세스 2와 연관되어 있다는 것을 알고 커널은 프로세스 1에서 핸들 2를 할당합니다. 따라서 프로세스 1은 핸들 2를 대상으로 하는 커널에 트랜잭션을 제출할 수 있으며, 이로부터 커널은 트랜잭션이 프로세스 2, 특히 해당 프로세스의 Object2b로 전송되고 있음을 확인할 수 있습니다.

프로세스 간 개체 매핑을 바인딩합니다.

파일 설명자와 마찬가지로 한 프로세스의 핸들 값은 다른 프로세스의 값과 다른 의미를 갖습니다. 예를 들어, 위 이미지에서 프로세스 1에서 핸들 값 2가 Object2a를 나타내는 것을 볼 수 있습니다. 그러나 프로세스 2에서는 동일한 핸들 값 2가 Object1a를 식별합니다. 게다가 커널이 다른 프로세스에 핸들을 할당하지 않으면 한 프로세스가 다른 프로세스의 개체에 액세스하는 것이 불가능합니다. 또한 위 이미지에서 커널은 프로세스 2의 Object2b에 대해 알고 있지만 프로세스 1에 핸들을 할당하지 않은 것을 볼 수 있습니다. 따라서 커널이 다른 프로세스에 핸들을 할당하더라도 프로세스 1은 객체 경로에 액세스할 수 없습니다.

이러한 핸들-객체 연관은 어떻게 처음 설정됩니까? Linux 파일 설명자와 달리 사용자 프로세스는 핸들을 직접 요청하지 않습니다. 대신 커널은 필요에 따라 프로세스에 핸들을 할당합니다. 프로세스는 아래 그림에 나와 있습니다. 여기에서는 이전 그림에서 프로세스 2에서 프로세스 1로 Object1b에 대한 참조가 생성되는 방식을 살펴보겠습니다. 핵심은 트랜잭션이 다이어그램 하단의 왼쪽에서 오른쪽으로 시스템을 통해 흐르는 방식입니다. 아래 다이어그램에 표시된 주요 단계는 다음과 같습니다.

  1. 프로세스 1은 로컬 주소 Object1b를 포함하는 초기 트랜잭션 구조를 생성합니다.

  2. 프로세스 1은 트랜잭션을 커널에 제출합니다.

  3. 커널은 트랜잭션의 데이터를 보고 Object1b 주소를 찾은 다음 이전에 이 주소를 몰랐기 때문에 이에 대한 새 항목을 생성합니다.

  4. 커널은 트랜잭션의 대상 핸들 2를 사용하여 이것이 프로세스 2의 Object2a에 대한 것인지 확인합니다.

  5. 이제 커널은 프로세스 2에 맞게 트랜잭션 헤더를 다시 작성하고 해당 대상을 Object2a 주소로 변경합니다.

  6. 커널은 또한 대상 프로세스의 트랜잭션 데이터를 다시 작성합니다. 여기에서는 프로세스 2가 아직 Object1b를 알지 못하므로 이에 대한 새 핸들 3을 생성합니다.

  7. 다시 작성된 트랜잭션은 실행을 위해 프로세스 2로 전송됩니다.

  8. 트랜잭션을 수신한 후 프로세스는 새로운 핸들 3이 있음을 발견하고 이를 사용 가능한 핸들 테이블에 추가합니다.

프로세스 간에 바인더 개체를 전송합니다.

트랜잭션의 개체가 수신 프로세스에 이미 알려진 경우 흐름은 비슷하지만 이제 커널은 이전에 할당된 핸들이나 수신 프로세스의 로컬 개체 포인터를 포함하도록 트랜잭션을 다시 작성하기만 하면 됩니다. 즉, 동일한 파일을 여러 번 열면 매번 다른 설명자가 할당되는 Linux 파일 설명자와 달리 동일한 개체를 프로세스에 여러 번 보내면 항상 동일한 ID가 생성됩니다. 바인더 IPC 시스템은 이러한 개체가 프로세스 간에 이동할 때 고유한 개체 ID를 유지합니다.

Binder 아키텍처는 기본적으로 Linux에 기능 기반 보안 모델을 도입합니다. 모든 Binder 개체는 함수입니다. 개체를 다른 프로세스로 보내면 해당 프로세스에 해당 기능이 부여됩니다. 그러면 수신 프로세스는 객체가 제공하는 모든 특성을 활용할 수 있습니다. 프로세스는 객체를 다른 프로세스로 보낸 다음 임의의 프로세스로부터 객체를 수신하고 수신된 객체가 원래 보낸 객체와 정확히 동일한지 여부를 식별할 수 있습니다.

18.13.7.2 바인더 사용자 공간 API

대부분의 사용자 공간 코드는 바인더 커널 모듈과 직접 상호 작용하지 않습니다. 대신 더 간단한 API를 제공하는 사용자 공간 객체 지향 라이브러리가 있습니다. 이러한 사용자 공간 API의 첫 번째 수준은 세 가지 클래스 형식으로 지금까지 논의한 커널 개념과 매우 직접적으로 매핑됩니다.

  1. IBinder는 Binder 객체의 추상 인터페이스입니다. 주요 메소드는 트랜잭션으로, 객체에 트랜잭션을 커밋합니다. 수신 트랜잭션의 구현은 로컬 프로세스 또는 다른 프로세스의 개체일 수 있습니다. 다른 프로세스에 있는 경우 앞서 설명한 바인더 커널 모듈을 통해 전달됩니다.

  2. 바인더는 특정 바인더 개체입니다. Binder 하위 클래스를 구현하면 다른 프로세스에서 호출할 수 있는 클래스가 제공되며, 핵심 메서드는 전송된 트랜잭션을 수신하는 onTransact입니다. Binder 하위 클래스의 주요 책임은 여기에서 수신한 트랜잭션 데이터를 살펴보고 적절한 작업을 수행하는 것입니다.

  3. Parcel은 바인더 트랜잭션에서 데이터를 읽고 쓰는 컨테이너입니다. 여기에는 형식화된 데이터 정수, 문자열 및 배열을 읽고 쓰는 방법이 있지만 가장 중요한 것은 커널이 해당 참조를 이해하고 프로세스 간에 전송할 수 있도록 적절한 데이터 구조를 사용하여 모든 IBinder 개체에 대한 참조를 읽고 쓸 수 있다는 것입니다.

아래 그림에서는 이러한 클래스가 함께 작동하는 방식을 설명합니다. 여기서는 Binder1b와 Binder2a가 특정 Binder 하위 클래스의 인스턴스임을 알 수 있습니다. IPC를 수행하기 위해 프로세스는 이제 필요한 데이터가 포함된 Parcel을 생성하고 아직 보지 못한 다른 클래스인 BinderProxy를 통해 이를 보냅니다. 이 클래스는 프로세스에 새 핸들이 나타날 때마다 생성되므로 트랜잭션 메서드가 호출에 대한 적절한 트랜잭션을 생성하고 이를 커널에 제출하는 IBinder 구현을 제공합니다.

따라서 앞서 본 커널 트랜잭션 구조는 사용자 공간 API에서 분할됩니다. 대상은 BinderProxy로 표시되고 해당 데이터는 Parcel에 저장됩니다. 앞에서 본 것처럼 트랜잭션은 커널을 통과하고 수신 중에 사용자 공간에 나타날 때 해당 대상을 사용하여 적절한 수신 바인더 개체를 결정하고 Parcel은 해당 데이터에서 구축되어 해당 개체의 onTransact 메서드에 전달됩니다. 이제 이 세 가지 클래스를 사용하면 IPC 코드 작성이 매우 쉬워집니다.

  1. 바인더의 서브클래스.

  2. onTransact를 구현하여 들어오는 호출을 디코딩하고 실행합니다.

  3. 이 개체의 트랜잭션 메서드에 전달될 수 있는 Parcel을 생성하는 적절한 코드를 구현합니다.

이 작업의 대부분은 마지막 두 단계, 즉 간단한 메소드 호출을 사용하여 프로그래밍하려는 방식을 IPC 수행에 필요한 작업으로 변환하는 데 필요한 역마샬링 및 마샬링 코드에 있습니다.

18.13.7.3 바인더 인터페이스 및 AIDL

BinderIPC의 마지막 부분은 가장 일반적으로 사용되는 고급 인터페이스 기반 프로그래밍 모델입니다. 여기서는 더 이상 바인딩 개체와 구획 데이터를 다루지 않고 인터페이스와 메서드 측면에서 생각합니다.

이 레이어의 주요 부분은 AIDL(Android 인터페이스 정의 언어용)이라는 명령줄 도구입니다. 이 도구는 인터페이스에 대한 추상적 설명을 가져와 해당 인터페이스를 정의하는 데 필요한 소스 코드를 생성하고 해당 인터페이스에 대한 원격 호출을 수행하는 데 필요한 적절한 마샬링 및 역마샬링 코드를 구현하는 인터페이스 컴파일러입니다.

다음 코드는 AIDL에 정의된 인터페이스의 간단한 예를 보여줍니다. 이 인터페이스는 IExample이라고 하며 문자열 매개변수를 허용하는 print 메소드를 포함합니다.

cpp
package com.example

interface IExample 
{
    void print(Str ing msg);
}

위 코드의 인터페이스 설명은 AIDL로 컴파일되어 아래 그림에 표시된 세 가지 Java 언어 클래스를 생성합니다.

  1. IExample은 Java 언어 인터페이스 정의를 제공합니다.

  2. IExample.Stub은 이 인터페이스에 의해 구현된 기본 클래스입니다. 이는 Binder에서 상속받습니다. 즉, IPC 호출의 수신자가 될 수 있습니다. 이는 구현되는 인터페이스이므로 IExample에서 상속됩니다. 이 클래스의 목적은 역정렬화를 수행하는 것입니다. 즉, 들어오는 onTransact 호출을 IExample에 대한 적절한 메서드 호출로 변환합니다. 그런 다음 해당 하위 클래스는 IExample 메서드 구현만 담당합니다.

  3. IExample.Proxy는 IPC 호출의 다른 쪽 끝이며 호출 그룹화 실행을 담당합니다. 이는 각 메서드를 구현하고, 호출을 적절한 Parcel 콘텐츠로 변환하고, 통신하는 IBinder의 트랜잭션 호출을 통해 전송하는 IExample의 구체적인 구현입니다.

AIDL 기반 바인딩 IPC의 전체 경로는 아래 그림에 표시됩니다.

18.13.8 안드로이드 앱

Android에서 제공하는 애플리케이션 모델은 Linux 셸의 일반 명령줄 환경이나 그래픽 사용자 인터페이스에서 실행되는 애플리케이션과 매우 다릅니다. 애플리케이션은 기본 진입점이 있는 실행 파일이 아니며 애플리케이션을 구성하는 모든 것(코드, 그래픽 리소스, 시스템에 대한 선언 및 기타 데이터)을 위한 컨테이너입니다.

관례적으로 Android 애플리케이션은 Android 패키지용 확장자 apk가 있는 파일입니다. 이 파일은 실제로 애플리케이션의 모든 콘텐츠를 포함하는 일반 zip 아카이브입니다. APK의 중요한 내용은 다음과 같습니다.

  1. 애플리케이션이 무엇인지, 어떤 기능을 하는지, 어떻게 실행하는지 설명하는 매니페스트입니다. 매니페스트는 애플리케이션의 패키지 이름, Java 스타일 범위 문자열(예: com.android.app.calculator)을 제공해야 합니다.

이를 고유하게 식별합니다.

  1. 사용자에게 표시되는 XML 데이터의 문자열, 레이아웃 및 기타 설명, 그래픽 비트맵 등을 포함하여 애플리케이션에 필요한 리소스입니다.

  2. 코드 자체는 Dalvik 바이트코드일 수도 있고 네이티브 라이브러리 코드일 수도 있습니다.

  3. 작성자를 안전하게 식별하기 위한 서명 정보.

애플리케이션의 핵심 부분은 매니페스트입니다. 이는 apk의 zip 네임스페이스 루트에 AndroidManifest.xml이라는 미리 컴파일된 XML 파일로 표시됩니다. 가상 이메일 애플리케이션에 대한 전체 매니페스트 선언의 예는 다음과 같습니다. 이를 통해 이메일을 보고 작성할 수 있으며, 사용자가 현재 애플리케이션에 있지 않은 경우에도 로컬 이메일 저장소를 서버와 동기화하는 데 필요한 구성 요소도 포함됩니다.

cpp
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.email">
  <application>
    <activity android:name="com.example.email.MailMainActivity">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category="" android:name="android.intent.categor y.LAUNCHER" />
      </intent-filter>
    </activity>
    <activity android:name="com.example.email.ComposeActivity">
      <intent-filter>
        <action android:name="android.intent.action.SEND" />
        <category="" android:name="android.intent.categor y.DEFAULT" />
        <data android:mimeType="*/*" />
      </intent-filter>
    </activity>
    <service="" android:name="com.example.email.SyncSer vice">
    </service>
    <receiver android:name="com.example.email.SyncControlReceiver">
      <intent-filter>
        <action android:name="android.intent.action.DEVICE STORAGE LOW" />
      </intent-filter>
      <intent-filter>
        <action android:name="android.intent.action.DEVICE STORAGE OKAY" />
      </intent-filter>
    </receiver>
    <provider android:name="com.example.email.EmailProvider" android:author="" ities="com.example.email.provider.email">
    </provider>
  </application>
</manifest>

Android 앱에는 사용자가 앱을 시작할 때 실행되는 간단한 기본 진입점이 없습니다. 대신, 애플리케이션이 수행할 수 있는 다양한 작업을 설명하는 매니페스트의 <application> 태그 아래에 다양한 진입점을 게시합니다. 이러한 진입점은 애플리케이션이 제공할 수 있는 핵심 동작 유형(활동, 수신기, 서비스 및 콘텐츠 공급자)을 정의하는 네 가지 유형으로 표시됩니다. 우리가 보여주는 예제에서는 일부 활동 및 기타 구성 요소 유형에 대한 하나의 선언을 보여 주지만 애플리케이션은 이들 중 하나 이상을 선언할 수 있습니다.

애플리케이션에 포함될 수 있는 네 가지 구성 요소 유형은 각각 시스템에서 서로 다른 의미와 목적을 갖습니다. 모든 경우에 android:name 속성은 시스템이 필요할 때 인스턴스화하는 구성 요소를 구현하는 애플리케이션 코드의 Java 클래스 이름을 제공합니다.

패키지 관리자는 모든 애플리케이션 패키지를 추적하는 데 사용되는 Android의 일부입니다. 이는 각 애플리케이션의 매니페스트를 구문 분석하여 그 안에 있는 정보를 수집하고 색인화합니다. 이 정보를 통해 현재 설치된 애플리케이션을 쿼리하고 관련 정보를 검색할 수 있는 도구를 클라이언트에 제공합니다. 또한 앱 설치(앱을 위한 저장 공간 생성 및 APK 무결성 보장) 및 제거(이전에 설치된 앱과 관련된 모든 항목 정리)에 필요한 모든 작업을 처리합니다.

애플리케이션은 매니페스트에서 진입점을 정적으로 선언하므로 설치 시 시스템에 등록하기 위해 코드를 실행할 필요가 없습니다. 이 디자인은 여러 면에서 시스템을 더욱 강력하게 만듭니다. 애플리케이션을 설치하는 데 애플리케이션 코드를 실행할 필요가 없고, 애플리케이션의 최상위 기능은 항상 매니페스트를 확인하여 확인할 수 있으며, 애플리케이션의 실제 기능과 동기화되지 않을 수 있는 애플리케이션 정보를 저장하기 위해 별도의 데이터베이스를 유지할 필요가 없으며(예: 업데이트 전반에 걸쳐) 애플리케이션이 제거된 후 애플리케이션에 대한 정보가 남지 않는다는 것을 보장합니다. 이러한 분산형 접근 방식은 Windows의 중앙 집중식 레지스트리로 인해 발생하는 많은 문제를 방지하기 위한 것입니다.

애플리케이션을 세분화된 구성 요소로 분해하는 것도 애플리케이션 간의 상호 운용성과 협업을 지원하려는 우리의 설계 목표에 기여합니다. 애플리케이션은 특정 기능을 제공하는 자체 조각을 게시할 수 있으며, 다른 응용 프로그램은 이러한 조각을 직접 또는 간접적으로 사용할 수 있습니다.

패키지 관리자 위에는 또 다른 중요한 시스템 서비스인 활동 관리자가 있습니다. 패키지 관리자는 설치된 모든 애플리케이션에 대한 정적 정보를 유지 관리하는 역할을 담당하지만 활동 관리자는 이러한 애플리케이션이 언제, 어디서, 어떻게 실행되어야 하는지 결정합니다. 이름에도 불구하고 실제로는 네 가지 유형의 애플리케이션 구성 요소를 모두 실행하고 각 구성 요소에 대한 적절한 동작을 구현하는 일을 담당합니다.

18.13.8.1 활동

활동은 사용자 인터페이스를 통해 사용자와 직접 상호 작용하는 애플리케이션의 일부입니다. 사용자가 자신의 기기에서 애플리케이션을 실행하면 실제로 애플리케이션 내부에 기본 진입점으로 지정된 활동이 있으며, 애플리케이션은 사용자와의 상호작용을 담당하는 활동에 코드를 구현합니다.

위의 xml 코드에 표시된 이메일 목록 예시에는 두 개의 캠페인이 포함되어 있습니다. 첫 번째는 사용자가 메일을 볼 수 있는 기본 메일 사용자 인터페이스이고, 두 번째는 새 메시지를 작성하기 위한 별도의 인터페이스이며, 첫 번째 메일 활동은 애플리케이션의 기본 진입점, 즉 사용자가 홈 화면에서 실행할 때 시작되는 활동으로 선언됩니다.

첫 번째 활동은 기본 활동이므로 사용자에게 기본 애플리케이션 실행기에서 시작할 수 있는 애플리케이션으로 표시됩니다. 그렇다면 시스템은 아래 이미지에 표시된 상태가 되며 왼쪽의 활동 관리자는 활동을 추적하기 위해 프로세스에서 내부 ActivityRecord 인스턴스를 생성합니다. 이러한 활동 중 하나 이상은 작업이라는 컨테이너로 구성되며, 이는 대략 사용자의 애플리케이션 경험에 해당합니다. 이 시점에서 활동 관리자는 해당 ActivityRecord와 연결된 기본 UI를 표시하기 위해 이메일 애플리케이션의 프로세스와 MainMailActivity 인스턴스를 시작했습니다. 이 활동은 이제 사용자 인터페이스의 포그라운드에 있으므로 '재개됨' 상태입니다.

이제 사용자가 이메일 애플리케이션을 종료하지 않고 종료하고 카메라 애플리케이션을 실행하여 사진을 찍는다면 아래 이미지와 같은 상태가 됩니다. 이제 재개된 활동인 활동 관리자에 연관된 ActivityRecord가 있는 카메라의 기본 활동을 실행하는 새로운 카메라 프로세스가 있습니다. 이전 이메일 활동에서도 흥미로운 일이 일어나고 있습니다. 이제 활동이 중지되고 ActivityRecord는 활동을 복원하는 대신 저장된 활동 상태를 저장합니다.

활동이 더 이상 포그라운드에 있지 않으면 시스템에서 "상태를 저장"하라는 요청을 받습니다. 여기에는 애플리케이션이 사용자가 현재 보고 있는 내용을 나타내는 최소한의 상태 정보를 생성하고 이 정보를 활동 관리자에게 반환하고 이를 시스템 서버 프로세스의 활동과 연결된 ActivityRecord에 저장하는 작업이 포함됩니다. 활동의 저장된 상태는 일반적으로 작습니다. 예를 들어 이메일의 스크롤 위치는 포함하지만 메시지 자체는 포함하지 않으며 애플리케이션이 영구 저장소의 다른 곳에 저장합니다.

Android에는 디스크의 파일(예: 코드)에서 깨끗한 RAM을 매핑하여 수행할 수 있는 페이징이 필요하지만 스왑 공간에 의존하지 않습니다. 즉, 애플리케이션 프로세스의 모든 더티 RAM 페이지는 RAM에 남아 있어야 합니다. 활동 관리자에 이메일의 주요 활동 상태를 안전하게 저장하면 시스템이 교환에서 제공하는 메모리를 처리할 때 어느 정도 유연성을 회복할 수 있습니다.

예를 들어, 카메라 애플리케이션이 많은 RAM을 필요로 하기 시작하면 시스템은 아래 이미지에 표시된 것처럼 이메일 프로세스를 간단히 제거할 수 있습니다. ActivityRecord 및 저장된 귀중한 상태는 활동 관리자에 의해 시스템 서버 프로세스에 안전하게 저장됩니다. 시스템 서버 프로세스는 Android의 모든 핵심 시스템 서비스를 호스팅하므로 항상 실행 중이어야 하므로 여기에 저장된 상태는 필요한 한 유지됩니다.

샘플 이메일 애플리케이션에는 기본 UI에 대한 활동뿐만 아니라 또 다른 ComposeActivity도 있습니다. 애플리케이션은 애플리케이션의 구현을 구성하는 데 도움이 될 수 있는 활동을 얼마든지 선언할 수 있지만 더 중요한 것은 애플리케이션 간 상호 작용을 활성화하는 데 사용할 수 있다는 것입니다. 예를 들어, 이는 여기 ComposeActivity가 참여하고 있는 Android의 교차 앱 공유 시스템의 기초입니다. 사용자가 카메라 앱을 사용하면서 찍은 사진을 공유하기로 결정한 경우 이메일 앱의 ComposeActivity는 공유 옵션 중 하나입니다. 이 옵션을 선택하면 활동이 시작되고 공유할 수 있는 이미지가 제공됩니다.

위에 표시된 활성 상태에서 이 스톡 옵션을 행사하면 아래와 같은 새로운 상태가 됩니다. 주의해야 할 몇 가지 중요한 사항이 있습니다.

  1. ComposeActivity를 실행하려면 먼저 이메일 애플리케이션 프로세스를 다시 시작해야 합니다.

  2. 그러나 이전 MailMainActivity는 필요하지 않기 때문에 지금은 시작되지 않습니다. 이렇게 하면 RAM 사용량이 줄어듭니다.

  3. 이제 카메라 작업에는 두 개의 레코드가 있습니다. 방금 입력한 원래 CameraMainActivity와 이제 표시된 새 ComposeActivity입니다. 사용자의 경우 이는 응집력 있는 작업으로 남아 있습니다. 사진을 이메일로 보내는 것은 현재 상호 작용하는 카메라입니다.

  4. 새로운 ComposeActivity가 맨 위에 있으므로 복원됩니다. 이전 CameraMainActivity는 더 이상 상단에 있지 않으므로 해당 상태가 저장됩니다. RAM이 다른 곳에서 필요한 경우 이 시점에서 해당 프로세스를 안전하게 종료할 수 있습니다.

마지막으로, 사용자가 마지막 상태(예: 사진을 공유하기 위해 이메일 작성)에 있는 동안 카메라 작업을 종료하고 이메일 애플리케이션으로 돌아오면 어떤 일이 발생하는지 살펴보겠습니다. 아래 이미지는 시스템의 새로운 상태를 보여줍니다. 이메일 작업과 해당 기본 활동을 다시 포그라운드로 가져와 MailMainActivity를 포그라운드 활동으로 만들었지만 현재 애플리케이션 프로세스에서 실행 중인 인스턴스가 없습니다.

이전 활동으로 돌아가기 위해 시스템은 새 인스턴스를 생성하여 이전 인스턴스에서 제공한 이전에 저장된 상태로 되돌립니다. 저장된 상태에서 활동을 복원하는 이 작업은 사용자가 마지막으로 종료했을 때와 동일한 시각적 상태로 활동을 복원해야 합니다. 이를 위해 애플리케이션은 저장된 상태에서 사용자가 있는 메시지를 찾고 영구 저장소에서 해당 메시지의 데이터를 로드한 다음 저장된 스크롤 위치 또는 기타 사용자 인터페이스 상태를 적용합니다.

18.13.8.2 서비스

서비스에는 두 가지 다른 ID가 있습니다.

  1. 독립적인 장기 실행 백그라운드 작업이 될 수 있습니다. 이러한 방식으로 서비스를 사용하는 일반적인 예로는 배경 음악 재생 수행, 사용자가 다른 애플리케이션을 사용하는 동안 활성 네트워크 연결(예: IRC 서버 사용) 유지, 백그라운드에서 데이터 다운로드 또는 업로드 등이 있습니다.

  2. 다른 애플리케이션이나 시스템이 해당 애플리케이션과 풍부하게 상호 작용할 수 있는 연결 지점 역할을 할 수 있습니다. 애플리케이션은 이를 사용하여 이미지 또는 오디오 처리 수행, 텍스트를 음성으로 변환 등과 같은 보안 API를 다른 애플리케이션에 제공할 수 있습니다.

앞에 표시된 예제 이메일 목록에는 사용자 사서함의 동기화를 수행하는 서비스가 포함되어 있습니다. 일반적인 구현에서는 서비스가 정기적인 간격(예: 15분마다)으로 실행되도록 예약하고, 서비스가 실행될 때 서비스를 시작하고, 완료되면 자체적으로 중지합니다.

이는 첫 번째 서비스 스타일, 장기 실행 백그라운드 작업의 일반적인 사용입니다. 아래 이미지는 이 경우의 시스템 상태를 보여주고 있는데, 매우 간단합니다. 활동 관리자는 서비스를 추적하기 위해 ServiceRecord를 생성하고 서비스가 시작되었음을 확인한 후 애플리케이션 프로세스에서 해당 SyncService 인스턴스를 생성합니다. 이 상태에서는 서비스가 완전히 활성화되고(wakelock을 보유하지 않으면 전체 시스템이 절전 모드로 전환되지 않음) 원하는 작업을 자유롭게 수행할 수 있습니다. 이 상태에서는 예를 들어 프로세스가 충돌하는 경우 애플리케이션의 프로세스가 떠날 수 있지만 활동 관리자는 ServiceRecord를 계속 유지하고 필요한 경우 서비스를 다시 시작하기로 결정할 수 있습니다.

다른 애플리케이션과 상호 작용하기 위한 연결 지점으로 서비스를 사용하는 방법을 이해하기 위해 기존 SyncService를 확장하여 다른 애플리케이션이 동기화 간격을 제어할 수 있는 API를 갖도록 한다고 가정해 보겠습니다. 아래와 같이 이 API에 대한 AIDL 인터페이스를 정의해야 합니다.

cpp
package com.example.email
    
interface ISyncControl 
{
    int getSyncInterval();
    void setSyncInterval(int seconds);
}

이 기능을 사용하기 위해 다른 프로세스가 애플리케이션 서비스에 바인딩되어 해당 인터페이스에 액세스할 수 있으며, 이는 아래 이미지에 표시된 대로 두 애플리케이션 간의 연결을 생성합니다. 이 프로세스의 단계는 다음과 같습니다.

  1. 클라이언트 애플리케이션은 서비스에 바인딩하고 싶다고 활동 관리자에게 알립니다.

  2. 서비스가 아직 생성되지 않은 경우 서비스 신청 과정에서 활동 관리자가 서비스를 생성합니다.

  3. 서비스는 해당 인터페이스의 IBinder를 활동 관리자에게 반환하며, 이제 활동 관리자는 해당 IBinder를 ServiceRecord에 저장합니다.

  4. 이제 활동 관리자에는 IBinder 서비스가 있으며 이를 원래 클라이언트 애플리케이션으로 다시 보낼 수 있습니다.

  5. 이제 서비스의 IBinder가 있는 클라이언트 애플리케이션은 해당 인터페이스에서 원하는 직접 호출을 계속할 수 있습니다.

18.13.8.3 수신자

수신자는 일반적으로 백그라운드에서 일반적인 사용자 상호 작용 외부에서 발생하는(대개 외부) 이벤트의 수신자입니다. 수신자는 개념적으로 흥미로운 일이 발생할 때(알람이 울리거나 데이터 연결이 변경되는 등) 콜백을 명시적으로 등록하는 애플리케이션과 동일하지만, 이벤트를 수신하기 위해 애플리케이션을 실행할 필요는 없습니다.

위에 표시된 이메일 목록 예시에는 기기의 저장 공간이 부족해지면 이메일 동기화를 중지하기 위해(더 많은 저장 공간을 소비할 수 있음) 애플리케이션이 검색할 수 있는 수신자가 포함되어 있습니다. 장치의 저장 공간이 부족해지면 시스템은 이벤트에 관심이 있는 모든 수신자에게 저장 공간이 적은 방송 코드를 보냅니다.

다음 다이어그램은 활동 관리자가 관심 있는 수신자에게 브로드캐스트를 전달하기 위해 이러한 브로드캐스트를 처리하는 방법을 보여줍니다. 먼저 패키지 관리자에게 이벤트에 관심이 있는 모든 수신기 목록을 요청합니다. 이 목록은 방송을 나타내는 방송 레코드에 저장됩니다. 그런 다음 활동 관리자는 목록의 각 항목을 진행하여 각 관련 애플리케이션의 프로세스가 해당 수신자 클래스를 생성하고 실행하도록 합니다.

수신기는 일회성 작업으로만 실행됩니다. 이벤트가 발생하면 시스템은 이에 관심이 있는 수신자를 찾아 이벤트를 전달하고, 해당 수신자가 이벤트를 소비하면 완료됩니다. 특정 수신기는 단일 브로드캐스트 동안 임시 엔터티일 뿐이므로 다른 애플리케이션 구성 요소에서 볼 수 있는 ReceiverRecord가 없습니다. 새로운 브로드캐스트가 수신자 구성 요소로 전송될 때마다 수신자 클래스의 새 인스턴스가 생성됩니다.

18.13.8.4 콘텐츠 제공자

최종 애플리케이션 구성요소인 콘텐츠 제공자는 애플리케이션이 서로 데이터를 교환하는 데 사용하는 기본 메커니즘입니다. 콘텐츠 제공자와의 모든 상호 작용은 content:scheme을 사용하는 URI를 통해 발생합니다. URI의 권한은 상호작용할 올바른 콘텐츠 제공자 구현을 찾는 데 사용됩니다.

예를 들어, 그림 10-51의 이메일 애플리케이션에서 콘텐츠 공급자는 해당 권한을 com.example.email.provider.email로 지정합니다. 따라서 이 콘텐츠 제공자에서 실행되는 URI는 content://com.example.email.provider.email/에서 시작하며, 제공자 자체에서 해석되는 URI의 접미사가 포함되어 액세스되는 데이터를 결정합니다. 여기 예에서 일반적인 규칙은 URI입니다: content://com.example.email.provider.email/messages는 모든 이메일 목록을 나타내는 반면, content://com.example.email.provider.email/messages/1은 키 번호 1에서 단일 메시지에 대한 액세스를 제공합니다.

콘텐츠 제공자와 상호작용하기 위해 애플리케이션은 항상 ContentResolver라는 시스템 API를 통과합니다. 여기서 대부분의 메소드에는 작동할 데이터를 나타내는 초기 URI 매개변수가 있습니다. 가장 일반적으로 사용되는 ContentResolver 메서드 중 하나는 지정된 URI에 대해 데이터베이스 쿼리를 수행하고 구조화된 결과를 검색하기 위해 Cursor를 반환하는 쿼리입니다. 예를 들어 사용 가능한 모든 이메일의 요약을 검색하는 방법은 다음과 같습니다.

cpp
query("content://com.example.email.provider.email/messages")

애플리케이션에서는 다르게 보이지만 콘텐츠 제공자를 사용할 때 실제로 발생하는 일은 서비스에 바인딩하는 것과 많은 유사점을 갖습니다. 아래 이미지는 시스템이 쿼리 예제를 처리하는 방법을 보여줍니다.

  1. 애플리케이션이 ContentResolver를 호출합니다. 작업을 시작하는 쿼리입니다.

  2. (패키지 관리자를 통해) 적절한 콘텐츠 제공자를 찾을 수 있도록 활동 관리자에게 URI 권한이 부여됩니다.

  3. 콘텐츠 제공자가 아직 실행되고 있지 않으면 생성됩니다.

  4. 일단 생성되면 콘텐츠 제공자는 해당 IBinder를 활동 관리자에게 반환하여 시스템의 IContentProvider 인터페이스를 구현합니다.

  5. ContentResolver에 대한 콘텐츠 제공자의 바인딩을 반환합니다.

  6. 이제 콘텐츠 파서는 AIDL 인터페이스에서 적절한 메서드를 호출하여 초기 쿼리 작업을 완료하고 커서 결과를 반환할 수 있습니다.

18.13.8.5 인텐트

앞서 표시된 애플리케이션 목록에서 아직 논의하지 않은 세부 사항 중 하나는 활동 및 수신자 선언에 포함된 <intent-filter> 태그입니다. 이는 Android 인텐트 기능의 일부이며 서로 다른 앱이 서로를 인식하여 함께 상호작용하고 작업할 수 있도록 하는 초석입니다.

인텐트는 Android가 활동, 수신자 및 서비스를 검색하고 식별하는 데 사용하는 메커니즘입니다. 이는 주어진 명령 이름과 일치하는 실행 파일을 찾기 위해 쉘이 여러 가능한 디렉토리를 조사하는 데 사용되는 Linux 쉘의 검색 경로와 어떤 면에서 유사합니다.

의도에는 명시적 의도와 암시적 의도의 두 가지 주요 유형이 있습니다. 명시적 인텐트는 단일 특정 애플리케이션 구성 요소를 직접 식별하는 인텐트입니다. Linux 셸 용어로 이는 명령에 대한 절대 경로를 제공하는 것과 같습니다. 이 인텐트의 가장 중요한 부분은 구성 요소의 이름을 지정하는 문자열 쌍, 즉 대상 애플리케이션의 패키지 이름과 해당 애플리케이션에 있는 구성 요소의 클래스 이름입니다. 이제 애플리케이션의 앞부분에 표시된 활동으로 돌아가면 이 구성 요소의 명시적 의도는 패키지 이름이 com.example, 이메일, 클래스 이름이 com.example.email.MailMainActivity인 구성 요소가 됩니다.

명시적 인텐트의 패키지 및 클래스 이름은 위에서 언급한 기본 이메일 캠페인과 같은 대상 구성 요소를 고유하게 식별하는 데 충분합니다. 패키지 이름에서 패키지 관리자는 코드가 있는 위치 등 애플리케이션에 필요한 모든 정보를 반환할 수 있습니다. 클래스 이름을 보면 코드의 어느 부분을 실행할지 알 수 있습니다.

암시적인 의도는 구성 요소 자체의 특성보다는 필수 구성 요소의 특성을 설명하는 것입니다. Linux 셸 용어로 이는 셸에서 실행할 특정 명령을 찾기 위해 검색 경로와 함께 사용하는 명령 이름을 셸에 제공하는 것과 같습니다. 암시적 의도와 일치하는 구성 요소를 찾는 프로세스를 의도 해결이라고 합니다.

18.13.9 애플리케이션 샌드박스

전통적으로 운영 체제에서 애플리케이션은 사용자로서 사용자를 대신하여 실행되는 코드로 간주되었습니다. 이 동작은 ls 명령을 실행하고 시스템에서 가지고 있는 것과 동일한 액세스 권한을 가진 ID(UID)로 실행될 것으로 예상하는 명령줄에서 상속됩니다. 마찬가지로, GUI를 사용하여 플레이하려는 게임을 시작할 때 게임은 사용자가 필요로 하지 않는 파일 및 기타 여러 항목에 액세스하여 효과적으로 실행됩니다.

그러나 이것은 오늘날 우리가 주로 컴퓨터를 사용하는 방식이 아닙니다. 우리는 광범위한 기능을 갖추고 우리가 거의 통제할 수 없는 환경에서 다양한 작업을 수행할 수 있는 신뢰도가 낮은 제3자 소스에서 얻은 응용 프로그램을 실행합니다. 운영 체제에서 지원하는 애플리케이션 모델과 실제로 사용되는 애플리케이션 사이에는 단절이 있습니다. 이는 일반 사용자 권한과 "관리자" 사용자 권한을 구별하고 처음으로 애플리케이션을 실행할 때 경고를 발행하는 등의 일부 전략을 통해 완화될 수 있지만 이러한 전략은 실제로 근본적인 연결 끊김 문제를 해결하지는 않습니다.

즉, 기존 운영 체제는 다른 사용자로부터 사용자를 보호하는 데는 매우 능숙하지만 사용자 자신으로부터는 사용자를 보호하는 데는 그다지 능숙하지 않습니다. 모든 프로그램은 사용자의 힘으로 실행되며, 그 중 어느 하나라도 잘못 작동하면 사용자에게 해를 끼칠 수 있습니다. 생각해 보십시오. UNIX 환경에서는 얼마나 많은 피해를 입힐 수 있습니까? 사용자가 접근할 수 있는 모든 정보는 공개될 수 있습니다. rm -rf*를 실행하여 멋지고 빈 홈 디렉터리를 만들 수 있습니다. 이 프로그램이 취약할 뿐만 아니라 악성인 경우 몸값을 대가로 모든 파일을 암호화할 수 있습니다. "당신의 힘"으로 모든 것을 실행하는 것은 위험합니다!

Android는 핵심 전제로 이 문제를 해결하려고 시도합니다. 앱은 실제로 사용자 기기에서 게스트로 실행되는 앱의 개발자입니다. 따라서 사용자가 명시적으로 승인하지 않은 민감한 정보로는 애플리케이션을 신뢰할 수 없습니다.

Android 구현에서 이 아이디어는 사용자 ID를 통해 상당히 직접적으로 표현됩니다. Android 애플리케이션이 설치되면 새로운 고유 Linux 사용자 ID(또는 UID)가 생성되고 모든 코드가 해당 "사용자"로 실행됩니다. 따라서 Linux 사용자 ID는 데스크톱 시스템에서 사용자를 위한 샌드박스를 만드는 것처럼 파일 시스템에 자체 격리된 영역이 있는 각 응용 프로그램에 대한 샌드박스를 만듭니다. 즉, Android는 Linux의 기존 기능을 새로운 방식으로 사용합니다. 결과적으로 더 나은 격리가 이루어집니다.

18.13.10 프로세스 모델

Linux의 전통적인 프로세스 모델은 새 프로세스의 포크를 만든 다음 exec를 만들고 실행할 코드로 프로세스를 초기화한 다음 실행을 시작하는 것입니다. 셸은 이 실행을 구동하고, 셸 명령을 실행하는 데 필요한 프로세스를 분기하고 실행하는 역할을 담당합니다. 이 명령이 종료되면 Linux는 프로세스를 삭제합니다.

Android는 약간 다른 프로세스를 사용합니다. 애플리케이션에 대한 이전 섹션에서 설명한 것처럼 활동 관리자는 실행 중인 애플리케이션을 관리하는 Android의 일부입니다. 새로운 애플리케이션 프로세스의 시작을 조정하고 그 안에서 실행될 항목과 더 이상 필요하지 않은 시기를 결정합니다.

18.13.10.1 예상과정

새 프로세스를 시작하려면 이벤트 관리자가 zygote와 통신해야 합니다. 활동 관리자가 처음 시작되면 zygote를 사용하여 개인 소켓을 생성하고 프로세스를 시작해야 할 때 이를 통해 명령을 보냅니다. 이 명령은 기본적으로 생성될 샌드박스, 즉 새 프로세스가 실행되어야 하는 UID와 여기에 적용될 기타 보안 제한 사항을 설명합니다. 따라서 Zygote는 루트로 실행되어야 합니다. 분기할 때 런타임에 적절한 UID를 설정하고 마지막으로 루트 권한을 제거하고 프로세스를 원하는 UID로 변경합니다.

활동 관리자가 활동 실행, 서비스, 브로드캐스트 및 콘텐츠 제공자에 대한 동적 정보를 유지 관리한다는 Android 애플리케이션에 대한 이전 논의를 떠올려 보세요. 이 정보를 사용하여 애플리케이션 프로세스를 생성하고 관리합니다. 예를 들어, 애플리케이션 실행 프로그램이 활동을 시작하기 위한 새로운 의도로 시스템을 호출하면 활동 관리자가 새 애플리케이션 실행을 담당합니다.

새로운 프로세스에서 활동을 시작하는 과정은 아래 그림과 같습니다. 그림의 각 단계에 대한 세부 내용은 다음과 같습니다.

  1. 일부 기존 프로세스(예: 애플리케이션 실행 프로그램)는 시작하려는 새 활동을 설명하기 위해 활동 관리자를 호출합니다.

  2. 활동 관리자는 패키지 관리자가 의도를 명시적인 구성 요소로 확인하도록 요구합니다.

  3. 활동 관리자는 애플리케이션의 프로세스가 아직 실행되지 않았음을 확인한 다음 적절한 UID를 가진 새 프로세스에 대해 zygote를 요청합니다.

  4. Zygote는 포크를 수행하고 자체 클론의 새 프로세스를 생성하며 권한을 제거하고 애플리케이션의 샌드박스에 맞게 UID를 설정하며 해당 프로세스에서 Dalvik의 초기화를 완료하여 Java 런타임이 완전히 실행됩니다. 예를 들어 포크 후에 가비지 수집기와 같은 스레드를 시작해야 합니다.

  5. 이제 새 프로세스는 Java 환경이 완전히 가동되어 실행되는 zygote 복제본입니다. 활동 관리자에게 전화를 걸어 "어떻게 해야 합니까?"라고 묻습니다.

  6. Activity Manager는 코드가 있는 위치 등 실행 중인 애플리케이션에 대한 전체 정보를 반환합니다.

  7. 새 프로세스는 실행 중인 애플리케이션의 코드를 로드합니다.

  8. 활동 관리자는 보류 중인 모든 작업을 새 프로세스(이 경우 "활동 X 시작")로 보냅니다.

  9. 새 프로세스는 활동을 시작하라는 명령을 수신하고 적절한 Java 클래스를 인스턴스화한 후 실행합니다.

이 활동을 시작할 때 애플리케이션의 프로세스가 이미 실행 중일 수 있습니다. 이 경우 활동 관리자는 끝으로 이동하여 프로세스에 새 명령을 보내 적절한 구성 요소를 인스턴스화하고 실행하도록 지시합니다. 이로 인해 애플리케이션에서 추가 활동 인스턴스가 실행될 수도 있습니다(해당되는 경우).

18.13.10.2 프로세스 수명주기

활동 관리자는 프로세스가 더 이상 필요하지 않은 시기를 결정하는 역할도 담당합니다. 프로세스에서 실행되는 모든 활동, 수신자, 서비스 및 콘텐츠 제공자를 추적하여 프로세스의 중요성(또는 중요성 부족)을 판단할 수 있습니다.

Android 커널의 메모리 부족 킬러는 어떤 프로세스를 먼저 종료해야 하는지 결정하기 위해 프로세스를 엄격한 순서로 사용한다는 점을 기억하세요. 활동 관리자는 프로세스를 기본 사용 범주로 나누어 프로세스 상태에 따라 각 프로세스의 oom_adj를 적절하게 설정하는 일을 담당합니다. 다음 표는 주요 카테고리를 보여줍니다. 가장 중요한 카테고리가 먼저 나열되고 마지막 열에는 해당 프로세스에 할당된 일반적인 oom_adj 값이 ​​표시됩니다.

분류 설명 oom_adj

시스템 시스템 및 데몬 프로세스 -16

지속성 항상 응용프로그램 프로세스 실행 -12

전경 현재 사용자와 상호작용 중 0

보이는 사용자에게 표시 1

인식 가능 사용자가 아는 것 2

서비스 백그라운드 서비스 실행 3

홈 홈/런처 프로세스 4

캐시됨 사용하지 않는 프로세스 5

이제 RAM이 부족해지면 시스템은 Out of Memory Killer가 캐시된 프로세스를 먼저 종료하여 필요한 RAM을 충분히 확보한 다음 홈 페이지, 서비스 등을 회수하도록 프로세스를 구성했습니다. 특정 OOM 조정 수준 내에서는 메모리 공간이 더 큰 프로세스를 먼저 종료한 다음 메모리 공간이 더 작은 프로세스를 종료합니다.

이제 Android가 프로세스 시작 시기를 결정하는 방법과 중요성에 따라 이러한 프로세스를 분류하는 방법을 살펴보았습니다. 이제 프로세스를 언제 종료할지 결정해야 합니다. 그렇죠? 아니면 여기서 더 많은 작업을 수행해야 합니까? 대답은, 우리는 그렇지 않다는 것입니다. Android에서는 애플리케이션 프로세스가 완전히 종료되지 않습니다. 시스템은 필요하지 않은 프로세스만 남기고 필요에 따라 프로세스를 선택하기 위해 커널에 의존합니다.

캐시 프로세스는 여러 면에서 Android에 부족한 스왑 공간을 대체합니다. RAM이 다른 곳에 필요하기 때문에 캐싱 프로세스는 활성 RAM에서 제거될 수 있습니다. 나중에 애플리케이션을 다시 실행해야 하는 경우 새 프로세스를 생성하여 사용자가 마지막으로 떠났을 때의 이전 상태로 복원할 수 있습니다. 뒤에서 운영 체제는 필요에 따라 프로세스를 시작, 종료 및 다시 시작하므로 중요한 전경 작업은 계속 실행되고 캐시된 프로세스는 RAM이 다른 곳에서 더 잘 사용되지 않는 한 유지됩니다.

18.13.10.3 프로세스 종속성

현재 단일 Android 프로세스를 관리하는 방법에 대한 좋은 개요가 있습니다. 그러나 프로세스 간의 종속성이라는 더 복잡한 문제가 있습니다.

예를 들어, 촬영한 사진을 저장하는 이전 카메라 애플리케이션을 생각해 보세요. 이 사진은 운영 체제의 일부가 아니며 카메라 앱의 콘텐츠 제공자가 구현합니다. 다른 응용 프로그램은 이 이미지 데이터에 액세스하고 카메라 응용 프로그램의 클라이언트가 되기를 원할 수 있습니다.

프로세스 간의 종속성은 콘텐츠 제공자(제공자에 대한 간단한 액세스를 통해)와 서비스(서비스에 대한 바인딩을 통해) 간에 발생할 수 있습니다. 두 경우 모두 운영 체제는 이러한 종속성을 추적하고 프로세스를 적절하게 관리해야 합니다.

프로세스 종속성은 프로세스가 생성되는 시기(및 프로세스 내에서 생성되는 구성 요소)와 프로세스의 oom_adj 중요성이라는 두 가지 주요 요소에 영향을 미칩니다. 프로세스의 중요성은 프로세스의 가장 중요한 구성 요소이며 프로세스의 중요성은 프로세스에 의존하는 가장 중요한 프로세스의 중요성이기도 함을 기억하십시오.

예를 들어 카메라 애플리케이션의 경우 해당 프로세스와 콘텐츠 제공자가 제대로 작동하지 않았습니다. 다른 프로세스가 이 콘텐츠 제공자에 액세스해야 할 때 생성됩니다. 카메라의 콘텐츠 제공자에 액세스할 때 카메라 프로세스는 적어도 이를 사용하는 프로세스만큼 중요한 것으로 간주됩니다.

각 프로세스의 궁극적 중요성을 계산하기 위해 시스템은 이러한 프로세스 간의 종속성 그래프를 유지해야 합니다. 각 프로세스에는 현재 실행 중인 모든 서비스와 콘텐츠 공급자의 목록이 있고, 각 서비스와 콘텐츠 공급자 자체에는 이를 사용하는 모든 프로세스의 목록이 있습니다. (이러한 목록은 활동 관리자 내의 기록에 보관되므로 애플리케이션이 이에 대해 거짓말을 하는 것은 불가능합니다.) 프로세스의 종속성 그래프를 탐색하려면 모든 콘텐츠 제공자와 서비스 및 이를 사용하는 프로세스를 탐색해야 합니다.

아래 다이어그램은 이들 간의 종속성을 고려한 일반적인 상태 프로세스를 보여줍니다. 이 예에는 두 가지 종속성이 포함되어 있으며 카메라 콘텐츠 공급자를 사용하여 이메일에 이미지 첨부 파일을 추가하는 것을 기반으로 합니다. 첫 번째는 카메라 앱을 사용하여 첨부 파일을 로드하는 현재 포그라운드 이메일 앱으로, 카메라 프로세스를 이메일 앱과 동일한 중요성으로 끌어올립니다. 두 번째는 음악 앱이 서비스를 사용하여 백그라운드에서 음악을 재생하는 동시에 사용자의 음악 미디어에 액세스하는 미디어 프로세스에 의존하는 유사한 상황입니다.

이메일 애플리케이션이 첨부 파일 로드를 완료하고 더 이상 카메라 콘텐츠 제공자를 사용하지 않도록 위 이미지의 상태가 변경되면 어떻게 될지 생각해 보세요. 아래 그림은 프로세스 상태가 어떻게 변경되는지 보여줍니다. 카메라 앱은 더 이상 필요하지 않으므로 전경 중요도에서 제외되어 캐시 수준으로 떨어졌으며, 카메라 캐시도 캐시된 LRU 목록에서 이전 지도 앱을 한 단계 아래로 이동시킵니다.

이 두 가지 예는 궁극적으로 캐싱 프로세스의 중요성을 보여줍니다. 이메일 애플리케이션이 카메라 공급자를 다시 사용해야 하는 경우 해당 공급자의 프로세스는 일반적으로 캐시된 프로세스로 남아 있습니다. 다시 사용하려면 프로세스를 다시 포그라운드로 설정하고 데이터베이스가 이미 초기화된 콘텐츠 제공자에 다시 연결하면 됩니다.

18.14 UE 플랫폼

이전 장에서는 운영 체제와 관련된 개념, 원리 및 작동 메커니즘에 대해 자세히 설명했습니다. 이 장에서는 UE의 운영 체제 캡슐화에 대해 설명합니다. 이 문서에서는 분석을 위해 UE5.0.5의 소스 코드를 사용합니다.

18.14.1 UE 플랫폼 개요

UE의 운영 체제 관련 인터페이스는 아래 그림과 같이 Core 폴더에 캡슐화되어 있습니다.

위 파일 중 일부는 HAL, 메모리 등과 같은 공통 인터페이스 레이어입니다. 그중 가장 중요한 클래스는 다름 아닌 대부분의 플랫폼의 공통 인터페이스를 추상화하는 FGenericPlatformMisc입니다. 이에 대해서는 이후 장에서 자세히 설명합니다.

18.14.2 FGenericPlatformMisc

FGenericPlatformMisc는 UE의 중요한 크로스 플랫폼 유형으로, 다른 모듈이 플랫폼 독립적인 작업을 구현할 수 있도록 대부분의 운영 체제의 작업 인터페이스를 추상화합니다. 물론, FGenericPlatformMisc는 인터페이스 세트만 선언하고 특정 구현은 다양한 서브클래스에 의해 구현됩니다. FGenericPlatformMisc의 주요 인터페이스는 다음과 같습니다:

cpp
// GenericPlatformMisc.h

struct CORE_API FGenericPlatformMisc
{
    // 플랫폼
    static void PlatformPreInit();
    static void PlatformInit();
    static void PlatformTearDown();
    static void RequestExit( bool Force );
    static void RequestExitWithStatus( bool Force, uint8 ReturnCode );
    static bool RestartApplication();
    static bool RestartApplicationWithCmdLine(const char* CmdLine);
    static void TearDown();
    
    // /UI
    static void PlatformHandleSplashScreen(bool ShowSplashScreen);
    static void HidePlatformStartupScreen();
    static EAppReturnType::Type MessageBoxExt( EAppMsgType::Type MsgType, const TCHAR* Text, const TCHAR* Caption );
    static void ShowConsoleWindow();
    static int GetMobilePropagateAlphaSetting();
    
    // /
    static void SetGracefulTerminationHandler();
    static void SetCrashHandler(void (* CrashHandler)(const FGenericCrashContext& Context));
    static bool SupportsFullCrashDumps();
    static uint32 GetLastError();
    static void SetLastError(uint32 ErrorCode);
    static void RaiseException( uint32 ExceptionCode );
    static void LowLevelOutputDebugString(const TCHAR *Message);
    static void VARARGS LowLevelOutputDebugStringf(const TCHAR *Format, ... );
    static void SetUTF8Output();
    static void LocalPrint( const TCHAR* Str );
    static bool IsLocalPrintThreadSafe();
    static bool HasSeparateChannelForDebugOutput();
    static const TCHAR* GetSystemErrorMessage(TCHAR* OutBuffer, int32 BufferCount, int32 Error);
    static void PromptForRemoteDebugging(bool bIsEnsure);

    // 변수、
    static FString GetEnvironmentVariable(const TCHAR* VariableName);
    static void SetEnvironmentVar(const TCHAR* VariableName, const TCHAR* Value);
    FORCEINLINE static int32 GetMaxPathLength();
    static const TCHAR* GetPathVarDelimiter();
    static bool IsValidAbsolutePathFormat(const FString& Path);
    static void NormalizePath(FString& InPath);
    static void NormalizePath(FStringBuilderBase& InPath);
    static const TCHAR* GetDefaultPathSeparator();
    static const TCHAR* RootDir();
    static TArray<FString> GetAdditionalRootDirectories();
    static void AddAdditionalRootDirectory(const FString& RootDir);
    static const TCHAR* EngineDir();
    static const TCHAR* LaunchDir();
    static void CacheLaunchDir();
    static const TCHAR* ProjectDir();
    static FString CloudDir();
    static bool HasProjectPersistentDownloadDir();
    static bool CheckPersistentDownloadStorageSpaceAvailable( uint64 BytesRequired, bool bAttemptToUseUI );
    static const TCHAR* GamePersistentDownloadDir();
    static const TCHAR* GeneratedConfigDir();
    static void SetOverrideProjectDir(const FString& InOverrideDir);

    // 디바이스/
    static FString GetDeviceId();
    static FString GetUniqueAdvertisingId();
    static void SubmitErrorReport( const TCHAR* InErrorHist, EErrorReportMode::Type InMode );
    static bool IsRemoteSession();
    FORCEINLINE static bool IsDebuggerPresent();
    static EProcessDiagnosticFlags GetProcessDiagnostics();
    static FString GetCPUVendor();
    static uint32 GetCPUInfo();
    static bool HasNonoptionalCPUFeatures();
    static bool NeedsNonoptionalCPUFeaturesCheck();
    static FString GetCPUBrand();
    static FString GetCPUChipset();
    static FString GetPrimaryGPUBrand();
    static FString GetDeviceMakeAndModel();
    static struct FGPUDriverInfo GetGPUDriverInfo(const FString& DeviceDescription);
    
    static void PrefetchBlock(const void* InPtr, int32 NumBytes = 1);
    static void Prefetch(void const* x, int32 offset = 0);

    static const TCHAR* GetDefaultDeviceProfileName();
    FORCEINLINE static int GetBatteryLevel();
    FORCEINLINE static void SetBrightness(float bBright);
    FORCEINLINE static float GetBrightness();
    FORCEINLINE static bool SupportsBrightness();
    FORCEINLINE static bool IsInLowPowerMode();
    static float GetDeviceTemperatureLevel();
    static inline int32 GetMaxRefreshRate();
    static inline int32 GetMaxSyncInterval();
    static bool IsPGOEnabled();
    
    static TArray<uint8> GetSystemFontBytes();
    static bool HasActiveWiFiConnection();
    static ENetworkConnectionType GetNetworkConnectionType();

    static bool HasVariableHardware();
    static bool HasPlatformFeature(const TCHAR* FeatureName);
    static bool IsRunningOnBattery();
    static EDeviceScreenOrientation GetDeviceOrientation();
    static void SetDeviceOrientation(EDeviceScreenOrientation NewDeviceOrientation);
    static int32 GetDeviceVolume();

    // OS
    static void GetOSVersions( FString& out_OSVersionLabel, FString& out_OSSubVersionLabel );
    static FString GetOSVersion();
    static bool GetDiskTotalAndFreeSpace( const FString& InPath, uint64& TotalNumberOfBytes, uint64& NumberOfFreeBytes );
    static bool GetPageFaultStats(FPageFaultStats& OutStats, EPageFaultFlags Flags);
    static bool GetBlockingIOStats(FProcessIOStats& OutStats, EInputOutputFlags Flags=EInputOutputFlags::All);
    static bool GetContextSwitchStats(FContextSwitchStats& OutStats, EContextSwitchFlags Flags=EContextSwitchFlags::All);
    static bool CommandLineCommands();
    static bool Is64bitOperatingSystem();
    static bool OsExecute(const TCHAR* CommandType, const TCHAR* Command, const TCHAR* CommandLine = NULL);
    static bool Exec(class UWorld* InWorld, const TCHAR* Cmd, FOutputDevice& Out);
   
    static bool GetSHA256Signature(const void* Data, uint32 ByteSize, FSHA256Signature& OutSignature);
    static FString GetDefaultLanguage();
    static FString GetDefaultLocale();
    static FString GetTimeZoneId();
    static TArray<FString> GetPreferredLanguages();
    
    static const TCHAR* GetUBTPlatform();
    static const TCHAR* GetUBTTarget();
    static void SetUBTTargetName(const TCHAR* InTargetName);
    static const TCHAR* GetUBTTargetName();
    static const TCHAR* GetNullRHIShaderFormat();
    static IPlatformChunkInstall* GetPlatformChunkInstall();
    static IPlatformCompression* GetPlatformCompression();
    
    static void GetValidTargetPlatforms(TArray<FString>& TargetPlatformNames);
    static FPlatformUserId GetPlatformUserForUserIndex(int32 LocalUserIndex);
    static int32 GetUserIndexForPlatformUser(FPlatformUserId PlatformUser);
   
    // /이벤트
    static bool SupportsMessaging();
    static bool SupportsLocalCaching();
    static bool AllowLocalCaching();
    static void BeginNamedEvent(const struct FColor& Color, const TCHAR* Text);
    static void BeginNamedEvent(const struct FColor& Color, const ANSICHAR* Text);
    template<typename CharType>
    static void StatNamedEvent(const CharType* Text);
    static void TickStatNamedEvents();
    static void LogNameEventStatsInit();
    static void EndNamedEvent();
    static void CustomNamedStat(const TCHAR* Text, float Value, const TCHAR* Graph, const TCHAR* Unit);
    static void CustomNamedStat(const ANSICHAR* Text, float Value, const ANSICHAR* Graph, const ANSICHAR* Unit);
    static void BeginEnterBackgroundEvent(const TCHAR* Text) ;
    static void EndEnterBackgroundEvent();
    static void BeginNamedEventFrame();
    
    static void RegisterForRemoteNotifications();
    static bool IsRegisteredForRemoteNotifications();
    static void UnregisterForRemoteNotifications();
    
    static void PumpMessagesOutsideMainLoop();
    static void PumpMessagesForSlowTask();
    static void PumpEssentialAppMessages();

    // 메모리
    static void MemoryBarrier();
    static void SetMemoryWarningHandler(void (* Handler)(const FGenericMemoryWarningContext& Context));
    static bool HasMemoryWarningHandler();

    // I/O
    static void InitTaggedStorage(uint32 NumTags);
    static void ShutdownTaggedStorage();
    static void TagBuffer(const char* Label, uint32 Category, const void* Buffer, size_t BufferSize);
    static bool SetStoredValues(const FString& InStoreId, const FString& InSectionName, const TMap<FString, FString>& InKeyValues);
    static bool SetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, const FString& InValue);
    static bool GetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, FString& OutValue);
    static bool DeleteStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName);
    static bool DeleteStoredSection(const FString& InStoreId, const FString& InSectionName);
    
    static TArray<FCustomChunk> GetOnDemandChunksForPakchunkIndices(const TArray<int32>& PakchunkIndices);
    static TArray<FCustomChunk> GetAllOnDemandChunks();
    static TArray<FCustomChunk> GetAllLanguageChunks();
    static TArray<FCustomChunk> GetCustomChunksByType(ECustomChunkType DesiredChunkType);
    static void ParseChunkIdPakchunkIndexMapping(TArray<FString> ChunkIndexRedirects, TMap<int32, int32>& OutMapping);
    static int32 GetChunkIDFromPakchunkIndex(int32 PakchunkIndex);
    static int32 GetPakchunkIndexFromPakFile(const FString& InFilename);
    
    static FText GetFileManagerName();
    static bool IsPackagedForDistribution();
    static FString LoadTextFileFromPlatformPackage(const FString& RelativePath);
    static bool FileExistsInPlatformPackage(const FString& RelativePath);
    
    static bool Expand16BitIndicesTo32BitOnLoad();
    static void GetNetworkFileCustomData(TMap<FString,FString>& OutCustomPlatformData);
    static bool SupportsBackbufferSampling();

    // 스레드(Thread)//비동기
    static bool UseRenderThread();
    static bool AllowAudioThread();
    static bool AllowThreadHeartBeat();
    static int32 NumberOfCores();
    static const FProcessorGroupDesc& GetProcessorGroupDesc();
    static int32 NumberOfCoresIncludingHyperthreads();
    static int32 NumberOfWorkerThreadsToSpawn();
    static int32 NumberOfIOWorkerThreadsToSpawn();
    static struct FAsyncIOSystemBase* GetPlatformSpecificAsyncIOSystem();
    static const TCHAR* GetPlatformFeaturesModuleName();
    static bool SupportsMultithreadedFileHandles();
    
    //
    static bool GetUseVirtualJoysticks();
    static bool SupportsTouchInput();
    static bool SupportsForceTouchInput();
    static bool ShouldDisplayTouchInterfaceOnFakingTouchEvents();
    static bool DesktopTouchScreen();
    static bool FullscreenSameAsWindowedFullscreen();
    static bool GetVolumeButtonsHandledBySystem();
    static void SetVolumeButtonsHandledBySystem(bool enabled);
    
    static void PrepareMobileHaptics(EMobileHapticsType Type);
    static void TriggerMobileHaptics();
    static void ReleaseMobileHaptics();

    // 정보
    static FString GetLoginId();
    static FString GetEpicAccountId();
    static FString GetOperatingSystemId();
    static EConvertibleLaptopMode GetConvertibleLaptopMode();
    static bool SupportsDeviceCheckToken();
    static bool RequestDeviceCheckToken(...);
    
    //
    static const TCHAR* GetEngineMode();
    static bool ShouldDisablePluginAtRuntime(const FString& PluginName);
    static bool UseHDRByDefault();
    static void ChooseHDRDeviceAndColorGamut(uint32 DeviceId, uint32 DisplayNitLevel, int32& OutputDevice, int32& ColorGamut);
    
    //
    static void CreateGuid(struct FGuid& Result);
    static void TickHotfixables();
    static FString GetLocalCurrencyCode();
    static FString GetLocalCurrencySymbol();
    static void ShareURL(const FString& URL, const FText& Description, int32 LocationHintX, int32 LocationHintY);

    (...)
};

위에서 볼 수 있듯이 FGenericPlatformMisc에는 OS 관련 인터페이스뿐만 아니라 장치, 하드웨어, 애플리케이션, 입력 및 출력, UI, 상호 작용, 멀티스레딩, 경로 및 기타 관련 인터페이스도 포함됩니다. 다음 그림은 상속 시스템 다이어그램입니다.

클래스 다이어그램-v2 FGenericPlatformMisc <|-- FWindowsPlatformMisc FGenericPlatformMisc <|-- FAndroidMisc FGenericPlatformMisc <|-- FApplePlatformMisc FApplePlatformMisc <|-- FMacPlatformMisc FApplePlatformMisc <|-- FIOSPlatformMisc FGenericPlatformMisc <|-- FUnixPlatformMisc FUnixPlatformMisc <|-- FLinuxPlatformMisc FGenericPlatformMisc <|-- FHoloLensMisc

다음 섹션에서는 분석을 위해 일부 플랫폼의 일부 인터페이스를 추출합니다.

18.14.2.1 FWindowsPlatformMisc

FWindowsPlatformMisc는 Windows 플랫폼의 관련 인터페이스를 구현합니다. 일부 인터페이스는 다음과 같이 분석됩니다.

cpp
// WindowsPlatformMisc.cpp

void FWindowsPlatformMisc::PlatformPreInit()
{
    FGenericPlatformMisc::PlatformPreInit();

    (...)
    
    // 을(를) 활용하여 의 처리(Process)호출(Call)객체/오브젝트 。
    DefaultPureCallHandler = _set_purecall_handler( PureCallHandler );

    const int32 MinResolution[] = {640, 480};
    if ( ::GetSystemMetrics(SM_CXSCREEN) < MinResolution[0] || ::GetSystemMetrics(SM_CYSCREEN) < MinResolution[1] )
    {
        FMessageDialog::Open( EAppMsgType::Ok, NSLOCTEXT("Launch", "Error_ResolutionTooLow", "The current resolution is too low to run this game.") );
        FPlatformMisc::RequestExit( false );
    }

    // 초기화(Initialize)SHA매핑
    InitSHAHashes();
}

void FWindowsPlatformMisc::PlatformInit()
{
    FGenericPlatformMisc::LogNameEventStatsInit();

    (...)

    // 설정(Set)로 1。
    timeBeginPeriod( 1 );

    (...)

    // 페치/가져오기(Fetch)cpu정보 .
    const FPlatformMemoryConstants& MemoryConstants = FPlatformMemory::GetConstants();
    UE_LOG(LogInit, Log, TEXT("CPU Page size=%i, Cores=%i"), MemoryConstants.PageSize, FPlatformMisc::NumberOfCores() );
    UE_LOG(LogInit, Log, TEXT("High frequency timer resolution =%f MHz"), 0.000001 / FPlatformTime::GetSecondsPerCycle() );

    // 스레드(Thread)。
    FWindowsPlatformStackWalk::RegisterOnModulesChanged();
}

// 페치/가져오기(Fetch)OS.
void FWindowsPlatformMisc::GetOSVersions( FString& OutOSVersionLabel, FString& OutOSSubVersionLabel )
{
    // OS초기화(Initialize).
    static struct FOSVersionsInitializer
    {
        FOSVersionsInitializer()
        {
            OSVersionLabel[0] = 0;
            OSSubVersionLabel[0] = 0;
            GetOSVersionsHelper( OSVersionLabel, UE_ARRAY_COUNT(OSVersionLabel), OSSubVersionLabel, UE_ARRAY_COUNT(OSSubVersionLabel) );
        }

        TCHAR OSVersionLabel[128];
        TCHAR OSSubVersionLabel[128];
    } OSVersionsInitializer;

    OutOSVersionLabel = OSVersionsInitializer.OSVersionLabel;
    OutOSSubVersionLabel = OSVersionsInitializer.OSSubVersionLabel;
}

// 페치/가져오기(Fetch)의 및 .
bool FWindowsPlatformMisc::GetDiskTotalAndFreeSpace( const FString& InPath, uint64& TotalNumberOfBytes, uint64& NumberOfFreeBytes )
{
    const FString ValidatedPath = FPaths::ConvertRelativePathToFull(InPath).Replace(TEXT("/"), TEXT("\\"));
    bool bSuccess = !!::GetDiskFreeSpaceEx( *ValidatedPath, nullptr, reinterpret_cast<ULARGE_INTEGER*>(&TotalNumberOfBytes), reinterpret_cast<ULARGE_INTEGER*>(&NumberOfFreeBytes));
    
    return bSuccess;
}

// 페치/가져오기(Fetch)정보 .
bool FWindowsPlatformMisc::GetPageFaultStats(FPageFaultStats& OutStats, EPageFaultFlags Flags/*=EPageFaultFlags::All*/)
{
    bool bSuccess = false;

    if (EnumHasAnyFlags(Flags, EPageFaultFlags::TotalPageFaults))
    {
        PROCESS_MEMORY_COUNTERS ProcessMemoryCounters;

        FPlatformMemory::Memzero(&ProcessMemoryCounters, sizeof(ProcessMemoryCounters));
        ::GetProcessMemoryInfo(::GetCurrentProcess(), &ProcessMemoryCounters, sizeof(ProcessMemoryCounters));

        OutStats.TotalPageFaults = ProcessMemoryCounters.PageFaultCount;

        bSuccess = true;
    }

    return bSuccess;
}

// 페치/가져오기(Fetch)IO상태 .
bool FWindowsPlatformMisc::GetBlockingIOStats(FProcessIOStats& OutStats, EInputOutputFlags Flags/*=EInputOutputFlags::All*/)
{
    bool bSuccess = false;
    IO_COUNTERS Counters;

    FPlatformMemory::Memzero(&Counters, sizeof(Counters));

    // Ignore flags as all values are grabbed at once
    if (::GetProcessIoCounters(::GetCurrentProcess(), &Counters) != 0)
    {
        OutStats.BlockingInput = Counters.ReadOperationCount;
        OutStats.BlockingOutput = Counters.WriteOperationCount;
        OutStats.BlockingOther = Counters.OtherOperationCount;
        OutStats.InputBytes = Counters.ReadTransferCount;
        OutStats.OutputBytes = Counters.WriteTransferCount;
        OutStats.OtherBytes = Counters.OtherTransferCount;

        bSuccess = true;
    }

    return bSuccess;
}

FString FWindowsPlatformMisc::GetOperatingSystemId()
{
    FString Result;
    QueryRegKey(HKEY_LOCAL_MACHINE, TEXT("Software\\Microsoft\\Cryptography"), TEXT("MachineGuid"), Result);
    return Result;
}

void FWindowsPlatformMisc::PumpMessagesOutsideMainLoop()
{
    TGuardValue<bool> PumpMessageGuard(GPumpingMessagesOutsideOfMainLoop, true);
    // 처리(Process)일시 중지 의 ,,D3D(IDXGISwapChain::Present)까지 스레드(Thread)의 뷰포트(Viewport),는 렌더링(Render)스레드(Thread)의 。
    MSG Msg;
    PeekMessage(&Msg, NULL, 0, 0, PM_NOREMOVE | PM_QS_SENDMESSAGE);
    return;
}

18.14.2.2 FAndroid기타

FAndroidMisc는 Android 플랫폼의 관련 인터페이스를 구현합니다. 일부 인터페이스는 다음과 같이 분석됩니다.

cpp
// AndroidPlatformMisc.cpp

void FAndroidMisc::RequestExit( bool Force )
{

#if PLATFORM_COMPILER_OPTIMIZATION_PG_PROFILING
    // 비활성화(Disable)기록/쓰기(Write)PGO구성(Configuration)。
    extern void PGO_WriteFile();
    if (!GIsCriticalError)
    {
        PGO_WriteFile();
        // 즉, 탈출/종료(Exit),로써 AndroidMain탈출/종료(Exit)의 회 PGO기록/쓰기(Write)。
        Force = true;
    }
#endif

    UE_LOG(LogAndroid, Log, TEXT("FAndroidMisc::RequestExit(%i)"), Force);
    if(GLog)
    {
        GLog->FlushThreadedLogs();
        GLog->Flush();
    }

    // 탈출/종료(Exit).
    if (Force) 
    {
#if USE_ANDROID_JNI
        AndroidThunkCpp_ForceQuit();
#else
        exit(1);
#endif
    }
    else
    {
        RequestEngineExit(TEXT("Android RequestExit"));
    }
}

// 초기화(Initialize)
void FAndroidMisc::PlatformInit()
{
    extern void AndroidSetupDefaultThreadAffinity();
    AndroidSetupDefaultThreadAffinity();

    (...)

    // 초기화(Initialize)JNI.
#if USE_ANDROID_JNI
    InitializeJavaEventReceivers();
    AndroidOnBackgroundBinding = FCoreDelegates::ApplicationWillEnterBackgroundDelegate.AddStatic(EnableJavaEventReceivers, false);
    AndroidOnForegroundBinding = FCoreDelegates::ApplicationHasEnteredForegroundDelegate.AddStatic(EnableJavaEventReceivers, true);
#endif

    // 초기화(Initialize)cpu.
    InitCpuThermalSensor();

    (...)
}

// 소멸
void FAndroidMisc::PlatformTearDown()
{
    auto RemoveBinding = [](FCoreDelegates::FApplicationLifetimeDelegate& ApplicationLifetimeDelegate, FDelegateHandle& DelegateBinding)
    {
        if (DelegateBinding.IsValid())
        {
            ApplicationLifetimeDelegate.Remove(DelegateBinding);
            DelegateBinding.Reset();
        }
    };

    RemoveBinding(FCoreDelegates::ApplicationWillEnterBackgroundDelegate, AndroidOnBackgroundBinding);
    RemoveBinding(FCoreDelegates::ApplicationHasEnteredForegroundDelegate, AndroidOnForegroundBinding);
}

// 는 을(를) 활용하여 렌더링(Render)스레드(Thread)
bool FAndroidMisc::UseRenderThread()
{
    // 만약 에 의해 ,을(를) 활용하여 렌더링(Render)스레드(Thread).
    if (!FGenericPlatformMisc::UseRenderThread())
    {
        return false;
    }

    // DeviceProfiles구성(Configuration) 내에서 의 DisableThreadedRendering CVar,비활성화(Disable)스레드(Thread)렌더링(Render)의 디바이스까지 개 디바이스구성(Configuration)을(를) 활용하여 CVar.
    const IConsoleVariable *const CVar = IConsoleManager::Get().FindConsoleVariable(TEXT("r.AndroidDisableThreadedRendering"));
    if (CVar && CVar->GetInt() != 0)
    {
        return false;
    }

    // tegra처리(Process),즉, optimus 2x 및 xoom스레드(Thread)。을(를) 활용하여 lg optimus 2x 및 motorola xoom의 opengl()처리(Process)스레드(Thread)。
    if (FAndroidMisc::GetGPUFamily() == FString(TEXT("NVIDIA Tegra")) && FPlatformMisc::NumberOfCores() <= 2 && FAndroidMisc::GetGLVersion().StartsWith(TEXT("OpenGL ES 2.")))
    {
        return false;
    }

    // 2.x의 Vivante GC1000렌더링(Render)스레드(Thread)
    if (FAndroidMisc::GetGPUFamily().StartsWith(TEXT("Vivante GC1000")) && FAndroidMisc::GetGLVersion().StartsWith(TEXT("OpenGL ES 2.")))
    {
        return false;
    }

    // 을(를) 활용하여 openglkindlefire(1)을(를) 활용하여 스레드(Thread)프레젠트 버퍼 .
    if (FAndroidMisc::GetDeviceModel() == FString(TEXT("Kindle Fire")))
    {
        return false;
    }

    // 을(를) 활용하여 opengl의 스레드(Thread)의 s3 mini,swapbuffer정렬 .
    if (FAndroidMisc::GetDeviceModel() == FString(TEXT("GT-I8190L")))
    {
        return false;
    }

    return true;
}

// 트리거 처리(Process)
void FAndroidMisc::TriggerCrashHandler(ECrashContextType InType, const TCHAR* InErrorMessage, const TCHAR* OverrideCallstack)
{
    if (InType != ECrashContextType::Crash)
    {
        // 신호(Signal),malloccrash。
        if (GLog)
        {
            GLog->PanicFlushThreadedLogs();
            GLog->Flush();
        }
        if (GWarn)
        {
            GWarn->Flush();
        }
        if (GError)
        {
            GError->Flush();
        }
    }

    FAndroidCrashContext CrashContext(InType, InErrorMessage);

    if (OverrideCallstack)
    {
        CrashContext.SetOverrideCallstack(OverrideCallstack);
    }
    else
    {
        CrashContext.CaptureCrashInfo();
    }

    if (GCrashHandlerPointer)
    {
        GCrashHandlerPointer(CrashContext);
    }
    else
    {
        // 기본값(Default)처리(Process).
        DefaultCrashHandler(CrashContext);
    }
}

// 설정(Set)처리(Process).
void FAndroidMisc::SetCrashHandler(void(*CrashHandler)(const FGenericCrashContext& Context))
{
#if ANDROID_HAS_RTSIGNALS
    GCrashHandlerPointer = CrashHandler;

    FFatalSignalHandler::Release();
    FThreadCallstackSignalHandler::Release();
    // -1복구 ,.
    if ((PTRINT)CrashHandler == -1)
    {
        return;
    }

    FFatalSignalHandler::Init();
    FThreadCallstackSignalHandler::Init();
#endif
}

// 는 Vulkan지원 .
bool FAndroidMisc::HasVulkanDriverSupport()
{
#if !USE_ANDROID_JNI
    VulkanSupport = EDeviceVulkanSupportStatus::NotSupported;
    VulkanVersionString = TEXT("0.0.0");
#else
    // VulkanRHI 또는 cvars비활성화(Disable)!
    if (VulkanSupport == EDeviceVulkanSupportStatus::Uninitialized)
    {
        //
        VulkanSupport = EDeviceVulkanSupportStatus::NotSupported;
        VulkanVersionString = TEXT("0.0.0");

        // libvulkan.so
        void* VulkanLib = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
        if (VulkanLib != nullptr)
        {
            // 만약 는 Nougat,로써 Vulkan
            if (FAndroidMisc::GetAndroidBuildVersion() >= 24)
            {
                extern int32 AndroidThunkCpp_GetMetaDataInt(const FString& Key);
                int32 VulkanVersion = AndroidThunkCpp_GetMetaDataInt(TEXT("android.hardware.vulkan.version"));
                if (VulkanVersion >= UE_VK_API_VERSION)
                {
                    // ,초기화(Initialize)인스턴스
                    VulkanSupport = AttemptVulkanInit(VulkanLib);
                }
            }
            else
            {
                // 이면 ,초기화(Initialize)인스턴스
                VulkanSupport = AttemptVulkanInit(VulkanLib);
            }

            dlclose(VulkanLib);

            if (VulkanSupport == EDeviceVulkanSupportStatus::Supported)
            {
                UE_LOG(LogAndroid, Log, TEXT("VulkanRHI is available, Vulkan capable device detected."));
                return true;
            }
            (...)
#endif
    return VulkanSupport == EDeviceVulkanSupportStatus::Supported;
}

// Vulkan는 을(를) 활용하여
bool FAndroidMisc::IsVulkanAvailable()
{
    (...)

    // VulkanRHI모듈 .
    if (!FModuleManager::Get().ModuleExists(TEXT("VulkanRHI")))
    {
        UE_LOG(LogAndroid, Log, TEXT("Vulkan not available as VulkanRHI not present."));
    }
    // bSupportsVulkan 또는 bSupportsVulkanSM5,Vulka로써 의 。
    else if (!(bSupportsVulkan || bSupportsVulkanSM5))
    {
        UE_LOG(LogAndroid, Log, TEXT("Vulkan not available as project packaged without bSupportsVulkan or bSupportsVulkanSM5."));
    }
    // Vulkan API검사/감지(Detect)에 의해 비활성화(Disable)。
    else if (bVulkanDisabledCmdLine)
    {
        UE_LOG(LogAndroid, Log, TEXT("Vulkan API detection is disabled by a command line option."));
    }
    // Vulkan을(를) 활용하여 ,그러나 AndroidRuntimeSettings 내에서 bDetectVulkanByDefault=False비활성화(Disable)검사/감지(Detect)。을(를) 활용하여 -detectvulkan。
    else if (!bDetectVulkanByDefault && !bDetectVulkanCmdLine)
    {
        UE_LOG(LogAndroid, Log, TEXT("Vulkan available but detection disabled by bDetectVulkanByDefault=False in AndroidRuntimeSettings. Use -detectvulkan to override."));
    }
    else
    {
        CachedVulkanAvailable = 1;
    }

    return CachedVulkanAvailable == 1;
}

// 검사/감지(Detect)는 을(를) 활용하여 Vulkan
bool FAndroidMisc::ShouldUseVulkan()
{
    static int CachedShouldUseVulkan = -1;

    if (CachedShouldUseVulkan == -1)
    {
        (...)
        // 만약 Vulkan을(를) 활용하여 변수비활성화(Disable)Vulkan, 이면 을(를) 활용하여 .
        if (bVulkanAvailable && !bVulkanDisabledCVar)
        {
            CachedShouldUseVulkan = 1;
            UE_LOG(LogAndroid, Log, TEXT("VulkanRHI will be used!"));
        }
        (...)
    }

    return CachedShouldUseVulkan == 1;
}

// 는 을(를) 활용하여 Vulkan.
bool FAndroidMisc::ShouldUseDesktopVulkan()
{
    (...)

    // 만약 VulkanSM5활성화(Enable)VulkanSM5비활성화(Disable), 이면 로써 .
    if (bVulkanSM5Enabled && !bVulkanSM5Disabled)
    {
        CachedShouldUseDesktopVulkan = 1;
        UE_LOG(LogAndroid, Log, TEXT("Vulkan SM5 RHI will be used!"));
    }
    
    (...)
}

// 페치/가져오기(Fetch)Vulkan.
FString FAndroidMisc::GetVulkanVersion()
{
    check(VulkanSupport != EDeviceVulkanSupportStatus::Uninitialized);
    return VulkanVersionString;
}

void FAndroidMisc::GetOSVersions(FString& out_OSVersionLabel, FString& out_OSSubVersionLabel)
{
    out_OSVersionLabel = TEXT("Android");
    out_OSSubVersionLabel = AndroidVersion;
}

FString FAndroidMisc::GetOSVersion()
{
    return AndroidVersion;
}

// 페치/가져오기(Fetch)정보 .
bool FAndroidMisc::GetDiskTotalAndFreeSpace(const FString& InPath, uint64& TotalNumberOfBytes, uint64& NumberOfFreeBytes)
{
    extern FString GExternalFilePath;
    struct statfs FSStat = { 0 };
    FTCHARToUTF8 Converter(*GExternalFilePath);
    int Err = statfs((ANSICHAR*)Converter.Get(), &FSStat);

    if (Err == 0)
    {
        TotalNumberOfBytes = FSStat.f_blocks * FSStat.f_bsize;
        NumberOfFreeBytes = FSStat.f_bavail * FSStat.f_bsize;
    }
    (...)

    return (Err == 0);
}

18.14.2.3 FIOSP플랫폼기타

FIOSPlatformMisc는 iOS 플랫폼의 관련 인터페이스를 구현합니다. 일부 인터페이스는 다음과 같이 분석됩니다.

cpp
// IOSPlatformMisc.cpp

// 초기화(Initialize).
void FIOSPlatformMisc::PlatformPreInit()
{
    FGenericPlatformMisc::PlatformPreInit();
    
    GIOSAppInfo.Init();
    
    // 비활성화(Disable)SIGPIPE
    signal(SIGPIPE, SIG_IGN);
}

// 초기화(Initialize).
void FIOSPlatformMisc::PlatformInit()
{
    // 생성(Create)프레임버퍼 의 UI스레드(Thread),“r.MobileContentScaleFactor”생성(Create)을(를) 활용하여 ,즉, 캐싱(Cache)。
    [[IOSAppDelegate GetDelegate] LoadMobileContentScaleFactor];
        
    FAppEntry::PlatformInit();

    // 추가(Add)의 의 개수 .
    struct rlimit Limit;
    Limit.rlim_cur = OPEN_MAX;
    Limit.rlim_max = RLIM_INFINITY;
    int32 Result = setrlimit(RLIMIT_NOFILE, &Limit);
    check(Result == 0);

    (...)
    
    // 메모리
    const FPlatformMemoryConstants& MemoryConstants = FPlatformMemory::GetConstants();
    GStartupFreeMemoryMB = GetFreeMemoryMB();

    // 생성(Create)Documents/<GameName>/Content,로써 로써 로부터 iCloud 내에서
    FString ResultStr = FPaths::ProjectContentDir();
    ResultStr.ReplaceInline(TEXT("../"), TEXT(""));
    (...)
    NSURL* URL = [NSURL fileURLWithPath : ResultStr.GetNSString()];
    if (![[NSFileManager defaultManager] fileExistsAtPath:[URL path]])
    {
        [[NSFileManager defaultManager] createDirectoryAtURL:URL withIntermediateDirectories : YES attributes : nil error : nil];
    }

    // 플래그 로 .
    NSError *error = nil;
    BOOL success = [URL setResourceValue : [NSNumber numberWithBool : YES] forKey : NSURLIsExcludedFromBackupKey error : &error];
    if (!success)
    {
        NSLog(@"Error excluding %@ from backup %@",[URL lastPathComponent], error);
    }

    (...)
}

// 탈출/종료(Exit).
void FIOSPlatformMisc::RequestExit(bool Force)
{
    if (Force)
    {
        FApplePlatformMisc::RequestExit(Force);
    }
    else
    {
        [[IOSAppDelegate GetDelegate] ForceExit];
    }
}

void FIOSPlatformMisc::RequestExitWithStatus(bool Force, uint8 ReturnCode)
{
    if (Force)
    {
        FApplePlatformMisc::RequestExit(Force);
    }
    else
    {
        (...)
        [[IOSAppDelegate GetDelegate] ForceExit];
    }
}

// 페치/가져오기(Fetch)플랫폼.
bool FIOSPlatformMisc::HasPlatformFeature(const TCHAR* FeatureName)
{
    if (FCString::Stricmp(FeatureName, TEXT("Metal")) == 0)
    {
        return [IOSAppDelegate GetDelegate].IOSView->bIsUsingMetal;
    }

    return FGenericPlatformMisc::HasPlatformFeature(FeatureName);
}

// 페치/가져오기(Fetch)디바이스구성(Configuration).
const TCHAR* FIOSPlatformMisc::GetDefaultDeviceProfileName()
{
    static FString IOSDeviceProfileName;
    if (IOSDeviceProfileName.Len() == 0)
    {
        IOSDeviceProfileName = TEXT("IOS");
        FString DeviceIDString = GetIOSDeviceIDString();

        TArray<FString> Mappings;
        if (ensure(GConfig->GetSection(TEXT("IOSDeviceMappings"), Mappings, GDeviceProfilesIni)))
        {
            for (const FString& MappingString : Mappings)
            {
                FString MappingRegex, ProfileName;
                if (MappingString.Split(TEXT("="), &MappingRegex, &ProfileName))
                {
                    const FRegexPattern RegexPattern(MappingRegex);
                    FRegexMatcher RegexMatcher(RegexPattern, *DeviceIDString);
                    if (RegexMatcher.FindNext())
                    {
                        IOSDeviceProfileName = ProfileName;
                        break;
                    }
                }
                
                (...)
            }
        }
    }

    return *IOSDeviceProfileName;
}

// 페치/가져오기(Fetch)기본값(Default)의 크기 .
int FIOSPlatformMisc::GetDefaultStackSize()
{
    return 512 * 1024;
}

// .
void FIOSPlatformMisc::GetOSVersions(FString& out_OSVersionLabel, FString& out_OSSubVersionLabel)
{
#if PLATFORM_TVOS
    out_OSVersionLabel = TEXT("TVOS");
#else
    out_OSVersionLabel = TEXT("IOS");
#endif
    NSOperatingSystemVersion IOSVersion;
    IOSVersion = [[NSProcessInfo processInfo] operatingSystemVersion];
    out_OSSubVersionLabel = FString::Printf(TEXT("%ld.%ld.%ld"), IOSVersion.majorVersion, IOSVersion.minorVersion, IOSVersion.patchVersion);
}

// 정보 .
bool FIOSPlatformMisc::GetDiskTotalAndFreeSpace(const FString& InPath, uint64& TotalNumberOfBytes, uint64& NumberOfFreeBytes)
{
    bool GetValueSuccess = false;
    
    NSNumber *FreeBytes = nil;
    NSURL *URL = [NSURL fileURLWithPath : NSHomeDirectory()];
    GetValueSuccess = [URL getResourceValue : &FreeBytes forKey : NSURLVolumeAvailableCapacityForImportantUsageKey error : nil];
    if (FreeBytes)
    {
        NumberOfFreeBytes = [FreeBytes longLongValue];
    }
    
    NSNumber *TotalBytes = nil;
    GetValueSuccess = GetValueSuccess &&[URL getResourceValue : &TotalBytes forKey : NSURLVolumeTotalCapacityKey error : nil];
    if (TotalBytes)
    {
        TotalNumberOfBytes = [TotalBytes longLongValue];
    }
    
    if (GetValueSuccess && (NumberOfFreeBytes > 0) && (TotalNumberOfBytes > 0))
    {
        return true;
    }
    
    (...)
}

// .
FString FIOSPlatformMisc::GetProjectVersion()
{
    NSDictionary* infoDictionary = [[NSBundle mainBundle] infoDictionary];
    FString localVersionString = FString(infoDictionary[@"CFBundleShortVersionString"]);
    return localVersionString;
}

// .
FString FIOSPlatformMisc::GetBuildNumber()
{
    NSDictionary* infoDictionary = [[NSBundle mainBundle]infoDictionary];
    FString BuildString = FString(infoDictionary[@"CFBundleVersion"]);
    return BuildString;
}

// 설정(Set)저장(Store).
bool FIOSPlatformMisc::SetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, const FString& InValue)
{
    NSUserDefaults* UserSettings = [NSUserDefaults standardUserDefaults];
    NSString* StoredValue = [NSString stringWithFString:InValue];
    [UserSettings setObject:StoredValue forKey:MakeStoredValueKeyName(InSectionName, InKeyName)];

    return true;
}

// 페치/가져오기(Fetch)저장(Store).
bool FIOSPlatformMisc::GetStoredValue(const FString& InStoreId, const FString& InSectionName, const FString& InKeyName, FString& OutValue)
{
    NSUserDefaults* UserSettings = [NSUserDefaults standardUserDefaults];
    NSString* StoredValue = [UserSettings objectForKey:MakeStoredValueKeyName(InSectionName, InKeyName)];
    if (StoredValue != nil)
    {
        OutValue = StoredValue;
        return true;
    }
    return false;
}

// 설정(Set)처리(Process).
void FIOSPlatformMisc::SetCrashHandler(void (* CrashHandler)(const FGenericCrashContext& Context))
{
    SCOPED_AUTORELEASE_POOL;
    
    GCrashHandlerPointer = CrashHandler;
    
    if (!FIOSApplicationInfo::CrashReporter && !FIOSApplicationInfo::CrashMalloc)
    {
        // 구성(Configuration)처리(Process)malloc,로 메모리 .
        FIOSApplicationInfo::CrashMalloc = new FIOSMallocCrashHandler(4*1024*1024);
        
        PLCrashReporterConfig* Config = [[[PLCrashReporterConfig alloc] initWithSignalHandlerType: PLCrashReporterSignalHandlerTypeBSD symbolicationStrategy: PLCrashReporterSymbolicationStrategyNone crashReportFolder: FIOSApplicationInfo::TemporaryCrashReportFolder().GetNSString() crashReportName: FIOSApplicationInfo::TemporaryCrashReportName().GetNSString()] autorelease];
        FIOSApplicationInfo::CrashReporter = [[PLCrashReporter alloc] initWithConfiguration: Config];
        
        PLCrashReporterCallbacks CrashReportCallback = {
            .version = 0,
            .context = nullptr,
            .handleSignal = PLCrashReporterHandler
        };
        
        [FIOSApplicationInfo::CrashReporter setCrashCallbacks: &CrashReportCallback];
        
        NSError* Error = nil;
        if ([FIOSApplicationInfo::CrashReporter enableCrashReporterAndReturnError: &Error])
        {
           // .
        }
        else
        {
            // 처리(Process).
            struct sigaction Action;
            FMemory::Memzero(&Action, sizeof(struct sigaction));
            // 저장(Save)처리(Process).
            Action.sa_sigaction = PlatformCrashHandler;
            sigemptyset(&Action.sa_mask);
            Action.sa_flags = SA_SIGINFO | SA_RESTART | SA_ONSTACK;
            
            sigaction(SIGQUIT, &Action, NULL);
            sigaction(SIGILL, &Action, NULL);
            sigaction(SIGEMT, &Action, NULL);
            sigaction(SIGFPE, &Action, NULL);
            sigaction(SIGBUS, &Action, NULL);
            sigaction(SIGSEGV, &Action, NULL);
            sigaction(SIGSYS, &Action, NULL);
            sigaction(SIGABRT, &Action, NULL);
        }
    }
}

Apple의 운영 체제(Mac, iOS)는 C++와 Object C를 혼합하여 사용하므로 위의 설명 중 일부는 분명히 C++와 다릅니다. 이에 놀라지 마시고 문법 오류라고 생각하지 마십시오.

18.14.2.4 FUnixPlatform기타

FUnixPlatformMisc는 Unix 시스템의 인터페이스를 구현합니다. 코드 분석의 일부는 다음과 같습니다.

cpp
// UnixPlatformMisc.cpp

// 초기화(Initialize)
void FUnixPlatformMisc::PlatformPreInit()
{
    FGenericPlatformMisc::PlatformPreInit();

    UnixCrashReporterTracker::PreInit();
}

void FUnixPlatformMisc::PlatformInit()
{
    // 플랫폼의 신호(Signal)처리(Process).
    InstallChildExitedSignalHanlder();

    // IsFirstInstance()을(를) 활용하여 ,는 개 .
    bool bFirstInstance = FPlatformProcess::IsFirstInstance();
    bool bIsNullRHI = !FApp::CanEverRender();

    bool bPreloadedModuleSymbolFile = FParse::Param(FCommandLine::Get(), TEXT("preloadmodulesymbols"));

    UnixPlatForm_CheckIfKSMUsable();

    FString GPUInfo = GetGPUInfo();

    (...)

    FPlatformTime::PrintCalibrationLog();

    (...)

    if (bPreloadedModuleSymbolFile)
    {
        UnixPlatformStackWalk_PreloadModuleSymbolFile();
    }

    if (FPlatformMisc::HasBeenStartedRemotely() || FPlatformMisc::IsDebuggerPresent())
    {
        // 즉, 출력
        setvbuf(stdout, NULL, _IONBF, 0);
    }

    if (FParse::Param(FCommandLine::Get(), TEXT("norandomguids")))
    {
        SysGetRandomSupported = 0;
    }

    // 을(를) 활용하여 ,그러나 활성화(Enable)LTO의 ,,이므로 을(를) 활용하여 을(를) 활용하여 VeryVerbose설정(Set)는 유효(Valid).
    extern uint8** GNameBlocksDebug;
    if (GNameBlocksDebug)
    {
        UE_LOG(LogInit, VeryVerbose, TEXT("GNameBlocksDebug Valid - %i"), !!GNameBlocksDebug);
    }
}

// 소멸 .
void FUnixPlatformMisc::PlatformTearDown()
{
    // 비활성화(Disable)신호(Signal),。
    if (GDeferedExitLogging)
    {
        uint8 OverriddenErrorLevel = 0;
        if (FPlatformMisc::HasOverriddenReturnCode(&OverriddenErrorLevel))
        {
            UE_LOG(LogCore, Log, TEXT("FUnixPlatformMisc::RequestExit(bForce=false, ReturnCode=%d)"), OverriddenErrorLevel);
        }
        else
        {
            UE_LOG(LogCore, Log, TEXT("FUnixPlatformMisc::RequestExit(false)"));
        }
    }

    UnixPlatformStackWalk_UnloadPreloadedModuleSymbol();
    FPlatformProcess::CeaseBeingFirstInstance();
}

// 단계 출력정보 .
void FUnixPlatformMisc::LowLevelOutputDebugString(const TCHAR *Message)
{
    static_assert(PLATFORM_USE_LS_SPEC_FOR_WIDECHAR, "Check printf format");
    fprintf(stderr, "%s", TCHAR_TO_UTF8(Message));    // there's no good way to implement that really
}

// OS정보 .
void FUnixPlatformMisc::GetOSVersions(FString& out_OSVersionLabel, FString& out_OSSubVersionLabel)
{
    out_OSVersionLabel = FString(TEXT("GenericLinuxVersion"));
    out_OSSubVersionLabel = GetKernelVersion();
    TMap<FString, FString> OsInfo = ReadConfigurationFile(TEXT("/etc/os-release"));
    if (OsInfo.Num() > 0)
    {
        FString* VersionAddress = OsInfo.Find(TEXT("PRETTY_NAME"));
        if (VersionAddress)
        {
            FString* VersionNameAddress = nullptr;
            if (VersionAddress->Equals(TEXT("Linux")))
            {
                VersionNameAddress = OsInfo.Find(TEXT("NAME"));
                if (VersionNameAddress != nullptr)
                {
                    VersionAddress = VersionNameAddress;
                }
            }

            out_OSVersionLabel = FString(*VersionAddress);
        }
    }
    (...)
}

// OS.
FString FUnixPlatformMisc::GetOperatingSystemId()
{
    (...)
    
    int OsGuidFile = open("/etc/machine-id", O_RDONLY);
    if (OsGuidFile != -1)
    {
        char Buffer[PlatformMiscLimits::MaxOsGuidLength + 1] = {0};
        ssize_t ReadBytes = read(OsGuidFile, Buffer, sizeof(Buffer) - 1);

        if (ReadBytes > 0)
        {
            CachedResult = UTF8_TO_TCHAR(Buffer);
        }

        close(OsGuidFile);
    }

    (...)
}

// 페치/가져오기(Fetch)정보 .
bool FUnixPlatformMisc::GetDiskTotalAndFreeSpace(const FString& InPath, uint64& TotalNumberOfBytes, uint64& NumberOfFreeBytes)
{
    struct statfs FSStat = { 0 };
    FTCHARToUTF8 Converter(*InPath);
    int Err = statfs((ANSICHAR*)Converter.Get(), &FSStat);
    if (Err == 0)
    {
        TotalNumberOfBytes = FSStat.f_blocks * FSStat.f_bsize;
        NumberOfFreeBytes = FSStat.f_bavail * FSStat.f_bsize;
    }
    (...)
    return (Err == 0);
}

// 설정(Set)저장(Store).
bool FUnixPlatformMisc::SetStoredValues(const FString& InStoreId, const FString& InSectionName, const TMap<FString, FString>& InKeyValues)
{
    const FString ConfigPath = FString(FPlatformProcess::ApplicationSettingsDir()) / InStoreId / FString(TEXT("KeyValueStore.ini"));

    FConfigFile ConfigFile;
    ConfigFile.Read(ConfigPath);

    for (auto const& InKeyValue : InKeyValues)
    {
        FConfigSection& Section = ConfigFile.FindOrAdd(InSectionName);

        FConfigValue& KeyValue = Section.FindOrAdd(*InKeyValue.Key);
        KeyValue = FConfigValue(InKeyValue.Value);
    }

    ConfigFile.Dirty = true;
    return ConfigFile.Write(ConfigPath);
}

Linux 플랫폼의 구현은 추가 수정 없이 Unix의 구현과 완전히 동일하다는 점은 언급할 가치가 있습니다.

18.14.2.5 F플랫폼기타

UE 개발 경험이 있거나 주의 깊은 학생들은 플랫폼 관련 인터페이스를 사용할 때 FGenericPlatformMisc 대신 FPlatformMisc를 사용한다는 사실을 발견했을 것입니다. 그렇다면 그들의 관계는 무엇입니까? 수수께끼를 풀려면 다음 코드에서 답을 얻어야 합니다.

cpp
// PreprocessorHelpers.h
#define COMPILED_PLATFORM_HEADER(Suffix) PREPROCESSOR_TO_STRING(PREPROCESSOR_JOIN(PLATFORM_HEADER_NAME/PLATFORM_HEADER_NAME, Suffix))

// PlatformMisc.h
#include "GenericPlatform/GenericPlatformMisc.h"
#include COMPILED_PLATFORM_HEADER(PlatformMisc.h)

위의 코드 조각에서 볼 수 있듯이 UE는 COMPILED_PLATFORM_HEADER(PlatformMisc.h) 매크로를 사용하여 현재 시스템에 해당하는 파일 경로를 생성합니다. 예:

cpp
Windows: "Windows/WindowsPlatformMisc.h"
Android: "Android/AndroidPlatformMisc.h"
IOS    : "IOS/IOSPlatformMisc.h"
Unix   : "Unix/UnixPlatformMisc.h"
Mac    : "Mac/MacPlatformMisc.h"

그런 다음 각 플랫폼의 XXXPlatformMisc.h에 typedef FXXXPlatformMisc FPlatformMisc가 있습니다. 예를 들면 다음과 같습니다.

python
// WindowsPlatformMisc.h
typedef FWindowsPlatformMisc FPlatformMisc;

// AndroidPlatformMisc.h
typedef FAndroidMisc FPlatformMisc;

// IOSPlatformMisc.h
typedef FIOSPlatformMisc FPlatformMisc;

// LinuxPlatformMisc.h
typedef FLinuxPlatformMisc FPlatformMisc;

위의 유형 재정의를 통해 FGenericPlatformMisc의 다양한 서브클래스는 통합 FPlatformMisc 유형을 사용할 수 있으며, 다른 모듈은 통합 유형 FPlatformMisc를 사용하여 크로스 플랫폼 목적을 달성하기 위해 OS 관련 인터페이스에 액세스할 수 있습니다.

그런데 COMPILED_PLATFORM_HEADER()는 다른 크로스 플랫폼 파일이나 모듈에서도 사용됩니다.

cpp
#include COMPILED_PLATFORM_HEADER(PlatformAGXConfig.h)
#include COMPILED_PLATFORM_HEADER(PlatformApplicationMisc.h)
#include COMPILED_PLATFORM_HEADER(PlatformSplash.h)
#include COMPILED_PLATFORM_HEADER(PlatformSurvey.h)
#include COMPILED_PLATFORM_HEADER(PlatformModuleDiagnostics.h)
#include COMPILED_PLATFORM_HEADER(CriticalSection.h)
#include COMPILED_PLATFORM_HEADER(PlatformCompilerPreSetup.h)
#include COMPILED_PLATFORM_HEADER(PlatformCompilerSetup.h)
#include COMPILED_PLATFORM_HEADER(Platform.h)
#include COMPILED_PLATFORM_HEADER(PlatformAffinity.h)
#include COMPILED_PLATFORM_HEADER(PlatformAtomics.h)
#include COMPILED_PLATFORM_HEADER(PlatformCrashContext.h)
#include COMPILED_PLATFORM_HEADER(PlatformFile.h)
#include COMPILED_PLATFORM_HEADER(PlatformMath.h)
#include COMPILED_PLATFORM_HEADER(PlatformMemory.h)
#include COMPILED_PLATFORM_HEADER(PlatformOutputDevices.h)
#include COMPILED_PLATFORM_HEADER(PlatformProcess.h)
#include COMPILED_PLATFORM_HEADER(PlatformProperties.h)
#include COMPILED_PLATFORM_HEADER(PlatformStackWalk.h)
#include COMPILED_PLATFORM_HEADER(PlatformString.h)
#include COMPILED_PLATFORM_HEADER(PlatformTime.h)
#include COMPILED_PLATFORM_HEADER(PlatformTLS.h)
#include COMPILED_PLATFORM_HEADER(PlatformHttp.h)
#include COMPILED_PLATFORM_HEADER(PlatformBackgroundHttp.h)
#include COMPILED_PLATFORM_HEADER(OpenGLDrvPrivate.h)
#include COMPILED_PLATFORM_HEADER_WITH_PREFIX(Apple/Platform, PlatformDynamicRHI.h)
#include COMPILED_PLATFORM_HEADER(StaticShaderPlatform.inl)
#include COMPILED_PLATFORM_HEADER(StaticFeatureLevel.inl)
#include COMPILED_PLATFORM_HEADER(DataDrivenShaderPlatformInfo.inl)
#include COMPILED_PLATFORM_HEADER_WITH_PREFIX(Framework/Text, PlatformTextField.h)

프로세스, 원자 작업, 중요 섹션, 스택 탐색, TLS, 메모리, 파일, 충돌 컨텍스트, 선호도, 컴파일러, 애플리케이션, 수학, HTTP, 셰이더, RHI 및 기타 모듈이 포함됩니다. 다음 섹션에서는 몇 가지 중요한 모듈을 분석합니다.

18.14.3 FGenericPlatformApplicationMisc

FGenericPlatformApplicationMisc의 크로스 플랫폼 및 구현 메커니즘은 FGenericPlatformMisc와 유사합니다. 그 진술을 살펴 보겠습니다.

cpp
// GenericPlatformApplicationMisc.h

struct APPLICATIONCORE_API FGenericPlatformApplicationMisc
{
    // App선언 .
    static class GenericApplication* CreateApplication();
    static void PreInit();
    static void Init();
    static void PostInit();
    static void TearDown();

    // 모듈 //디바이스
    static void LoadPreInitModules();
    static void LoadStartupModules();
    static FOutputDeviceConsole* CreateConsoleOutputDevice();
    static FOutputDeviceError* GetErrorOutputDevice();
    static FFeedbackContext* GetFeedbackContext();
    static bool IsThisApplicationForeground();    
    static void RequestMinimize();
    static bool RequiresVirtualKeyboard();
    static void PumpMessages(bool bFromMainLoop);

    // /
    static void PreventScreenSaver();
    static bool IsScreensaverEnabled();
    static bool ControlScreensaver(EScreenSaverAction Action);
    static struct FLinearColor GetScreenPixelColor(const FVector2D& InScreenPos, float InGamma);
    static bool GetWindowTitleMatchingText(const TCHAR* TitleStartsWith, FString& OutTitle);
    static void SetHighDPIMode();
    static float GetDPIScaleFactorAtPoint(float X, float Y);
    static bool IsHighDPIAwarenessEnabled();
    static bool AnchorWindowWindowPositionTopLeft();
    static EScreenPhysicalAccuracy GetPhysicalScreenDensity(int32& OutScreenDensity);
    static EScreenPhysicalAccuracy ComputePhysicalScreenDensity(int32& OutScreenDensity);
    static EScreenPhysicalAccuracy ConvertInchesToPixels(T Inches, T2& OutPixels);
    static EScreenPhysicalAccuracy ConvertPixelsToInches(T Pixels, T2& OutInches);

    //
    static void SetGamepadsAllowed(bool bAllowed);
    static void SetGamepadsBlockDeviceFeedback(bool bAllowed);
    static void ResetGamepadAssignments();
    static void ResetGamepadAssignmentToController(int32 ControllerId);
    static bool IsControllerAssignedToGamepad(int32 ControllerId);
    static FString GetGamepadControllerName(int32 ControllerId);
    static class UTexture2D* GetGamepadButtonGlyph(...);
    static void EnableMotionData(bool bEnable);
    static bool IsMotionDataEnabled();
    
    //
    static void ClipboardCopy(const TCHAR* Str);
    static void ClipboardPaste(class FString& Dest);
    
    (...)
};

FGenericPlatformApplicationMisc는 주로 애플리케이션의 선언 주기, 창, 화면, 장치, 컨트롤러 등에 대한 통합 인터페이스를 제공하고 특정 구현은 다양한 플랫폼 하위 클래스에 의해 구현된다는 것을 알 수 있습니다. 다음 섹션에서는 일부 플랫폼의 일부 인터페이스를 분석합니다.

18.14.3.1 FWindowsPlatformApplicationMisc

FWindowsPlatformApplicationMisc는 Windows 플랫폼 애플리케이션의 인터페이스를 구현합니다.

cpp
// WindowsPlatformApplicationMisc.cpp

// 생성(Create)적용
GenericApplication* FWindowsPlatformApplicationMisc::CreateApplication()
{
    HICON AppIconHandle = LoadIcon( hInstance, MAKEINTRESOURCE( GetAppIcon() ) );
    if( AppIconHandle == NULL )
    {
        AppIconHandle = LoadIcon( (HINSTANCE)NULL, IDI_APPLICATION ); 
    }

    // 생성(Create)적용 .
    return FWindowsApplication::CreateWindowsApplication( hInstance, AppIconHandle );
}

void FWindowsPlatformApplicationMisc::PreInit()
{
    FApp::SetHasFocusFunction(&FWindowsPlatformApplicationMisc::IsThisApplicationForeground);
}

void FWindowsPlatformApplicationMisc::LoadStartupModules()
{
    FModuleManager::Get().LoadModule(TEXT("HeadMountedDisplay"));
    (...)
}

class FFeedbackContext* FWindowsPlatformApplicationMisc::GetFeedbackContext()
{
    (...)
    return FPlatformOutputDevices::GetFeedbackContext();
}

// .
void FWindowsPlatformApplicationMisc::PumpMessages(bool bFromMainLoop)
{
    const bool bSetPumpingMessages = !GPumpingMessages;
    if (bSetPumpingMessages)
    {
        GPumpingMessages = true;
    }

    ON_SCOPE_EXIT
    {
        if (bSetPumpingMessages)
        {
            GPumpingMessages = false;
        }
    };

    if (!bFromMainLoop)
    {
        FPlatformMisc::PumpMessagesOutsideMainLoop();
        return;
    }

    GPumpingMessagesOutsideOfMainLoop = false;
    WinPumpMessages();

    // 적용 는 포인트 .
    bool bHasFocus = FApp::HasFocus();
    static bool bHadFocus = false;

    (...)

#if !UE_SERVER
    // ,는 포인트 .
    if( bHadFocus != bHasFocus )
    {
        FGenericCrashContext::SetEngineData(TEXT("Platform.AppHasFocus"), bHasFocus ? TEXT("true") : TEXT("false"));
    }
#endif

    bHadFocus = bHasFocus;

    // 만약 는 의 ,,이면 적용 .
    FApp::SetVolumeMultiplier( bHasFocus ? 1.0f : FApp::GetUnfocusedVolumeMultiplier() );
}

// 설정(Set)DPI.
void FWindowsPlatformApplicationMisc::SetHighDPIMode()
{
    if (IsHighDPIAwarenessEnabled())
    {
        if (void* ShCoreDll = FPlatformProcess::GetDllHandle(TEXT("shcore.dll")))
        {
            typedef enum _PROCESS_DPI_AWARENESS {
                PROCESS_DPI_UNAWARE = 0,
                PROCESS_SYSTEM_DPI_AWARE = 1,
                PROCESS_PER_MONITOR_DPI_AWARE = 2
            } PROCESS_DPI_AWARENESS;

            // 로부터 shcore.dll페치/가져오기(Fetch)SetProcessDpiAwarenessProc인터페이스 .
            typedef HRESULT(STDAPICALLTYPE *SetProcessDpiAwarenessProc)(PROCESS_DPI_AWARENESS Value);
            SetProcessDpiAwarenessProc SetProcessDpiAwareness = (SetProcessDpiAwarenessProc)FPlatformProcess::GetDllExport(ShCoreDll, TEXT("SetProcessDpiAwareness"));
            GetDpiForMonitor = (GetDpiForMonitorProc)FPlatformProcess::GetDllExport(ShCoreDll, TEXT("GetDpiForMonitor"));

            typedef HRESULT(STDAPICALLTYPE *GetProcessDpiAwarenessProc)(HANDLE hProcess, PROCESS_DPI_AWARENESS* Value);
            GetProcessDpiAwarenessProc GetProcessDpiAwareness = (GetProcessDpiAwarenessProc)FPlatformProcess::GetDllExport(ShCoreDll, TEXT("GetProcessDpiAwareness"));

            if (SetProcessDpiAwareness && GetProcessDpiAwareness && !IsRunningCommandlet() && !FApp::IsUnattended())
            {
                PROCESS_DPI_AWARENESS CurrentAwareness = PROCESS_DPI_UNAWARE;

                GetProcessDpiAwareness(nullptr, &CurrentAwareness);

                if (CurrentAwareness != PROCESS_PER_MONITOR_DPI_AWARE)
                {
                    UE_LOG(LogInit, Log, TEXT("Setting process to per monitor DPI aware"));
                    HRESULT Hr = SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE); // 만약 ,이면 
                    if (Hr != S_OK)
                    {
                        UE_LOG(LogInit, Warning, TEXT("SetProcessDpiAwareness failed.  Error code %x"), Hr);
                    }
                }
            }

            FPlatformProcess::FreeDllHandle(ShCoreDll);
        }
        else if (void* User32Dll = FPlatformProcess::GetDllHandle(TEXT("user32.dll")))
        {
            // user32.dll페치/가져오기(Fetch)SetProcessDpiAware인터페이스 .
            typedef BOOL(WINAPI *SetProcessDpiAwareProc)(void);
            SetProcessDpiAwareProc SetProcessDpiAware = (SetProcessDpiAwareProc)FPlatformProcess::GetDllExport(User32Dll, TEXT("SetProcessDPIAware"));

            if (SetProcessDpiAware && !IsRunningCommandlet() && !FApp::IsUnattended())
            {
                UE_LOG(LogInit, Log, TEXT("Setting process to DPI aware"));

                BOOL Result = SetProcessDpiAware();
                if (Result == 0)
                {
                    UE_LOG(LogInit, Warning, TEXT("SetProcessDpiAware failed"));
                }
            }

            FPlatformProcess::FreeDllHandle(User32Dll);
        }
    }
}

// 페치/가져오기(Fetch)DPI.
int32 FWindowsPlatformApplicationMisc::GetMonitorDPI(const FMonitorInfo& MonitorInfo)
{
    int32 DisplayDPI = 96;

    if (IsHighDPIAwarenessEnabled())
    {
        if (GetDpiForMonitor)
        {
            RECT MonitorDim;
            MonitorDim.left = MonitorInfo.DisplayRect.Left;
            MonitorDim.top = MonitorInfo.DisplayRect.Top;
            MonitorDim.right = MonitorInfo.DisplayRect.Right;
            MonitorDim.bottom = MonitorInfo.DisplayRect.Bottom;

            HMONITOR Monitor = MonitorFromRect(&MonitorDim, MONITOR_DEFAULTTONEAREST);
            if (Monitor)
            {
                uint32 DPIX = 0;
                uint32 DPIY = 0;
                // 페치/가져오기(Fetch)DPI.
                if (SUCCEEDED(GetDpiForMonitor(Monitor, 0, &DPIX, &DPIY)))
                {
                    DisplayDPI = DPIX;
                }
            }
        }
        else
        {
            HDC Context = GetDC(nullptr);
            DisplayDPI = GetDeviceCaps(Context, LOGPIXELSX);
            ReleaseDC(nullptr, Context);
        }
    }

    return DisplayDPI;
}

// 페치/가져오기(Fetch)포인트 의 DPI
float FWindowsPlatformApplicationMisc::GetDPIScaleFactorAtPoint(float X, float Y)
{
    float Scale = 1.0f;

    if (IsHighDPIAwarenessEnabled())
    {
        if (GetDpiForMonitor)
        {
            POINT Position = { static_cast<LONG>(X), static_cast<LONG>(Y) };
            HMONITOR Monitor = MonitorFromPoint(Position, MONITOR_DEFAULTTONEAREST);
            if (Monitor)
            {
                uint32 DPIX = 0;
                uint32 DPIY = 0;
                if (SUCCEEDED(GetDpiForMonitor(Monitor, 0, &DPIX, &DPIY)))
                {
                    Scale = (float)DPIX / 96.0f;
                }
            }
        }
        else
        {
            HDC Context = GetDC(nullptr);
            int32 DPI = GetDeviceCaps(Context, LOGPIXELSX);
            Scale = (float)DPI / 96.0f;
            ReleaseDC(nullptr, Context);
        }
    }

    return Scale;
}

18.14.3.2 FAndroidApplicationMisc

FAndroidApplicationMisc는 Android 플랫폼 애플리케이션의 인터페이스를 구현합니다.

cpp
// AndroidPlatformApplicationMisc.cpp

void FAndroidApplicationMisc::LoadPreInitModules()
{
    FModuleManager::Get().LoadModule(TEXT("OpenGLDrv"));
#if USE_ANDROID_AUDIO
    FModuleManager::Get().LoadModule(TEXT("AndroidAudio"));
    FModuleManager::Get().LoadModule(TEXT("AudioMixerAndroid"));
#endif
}

class FFeedbackContext* FAndroidApplicationMisc::GetFeedbackContext()
{
    static FAndroidFeedbackContext Singleton;
    return &Singleton;
}

class FOutputDeviceError* FAndroidApplicationMisc::GetErrorOutputDevice()
{
    static FAndroidErrorOutputDevice Singleton;
    return &Singleton;
}

GenericApplication* FAndroidApplicationMisc::CreateApplication()
{
    return FAndroidApplication::CreateAndroidApplication();
}

void FAndroidApplicationMisc::SetGamepadsAllowed(bool bAllowed)
{
    if (FAndroidInputInterface* InputInterface = (FAndroidInputInterface*)FAndroidApplication::Get()->GetInputInterface())
    {
        InputInterface->SetGamepadsAllowed(bAllowed);
    }
}

// 계산/산출(Calculate)의 .
ScreenPhysicalAccuracy FAndroidApplicationMisc::ComputePhysicalScreenDensity(int32& OutScreenDensity)
{
    FString MyDeviceModel = FPlatformMisc::GetDeviceModel();

    TArray<FString> DeviceStrings;
    GConfig->GetArray(TEXT("DeviceScreenDensity"), TEXT("Devices"), DeviceStrings, GEngineIni);

    TArray<FScreenDensity> Devices;
    for ( const FString& DeviceString : DeviceStrings )
    {
        FScreenDensity DensityEntry;
        if ( DensityEntry.InitFromString(DeviceString) )
        {
            Devices.Add(DensityEntry);
        }
    }

    for ( const FScreenDensity& Device : Devices )
    {
        if ( Device.IsMatch(MyDeviceModel) )
        {
            OutScreenDensity = Device.Density * GetWindowUpscaleFactor();
            return EScreenPhysicalAccuracy::Truth;
        }
    }

    // JNI
#if USE_ANDROID_JNI
    extern FString AndroidThunkCpp_GetMetaDataString(const FString& Key);
    FString DPIStrings = AndroidThunkCpp_GetMetaDataString(TEXT("unreal.displaymetrics.dpi"));
    TArray<FString> DPIValues;
    DPIStrings.ParseIntoArray(DPIValues, TEXT(","));

    float xdpi, ydpi;
    LexFromString(xdpi, *DPIValues[0]);
    LexFromString(ydpi, *DPIValues[1]);

    OutScreenDensity = ( xdpi + ydpi ) / 2.0f;

    if ( OutScreenDensity <= 0 || OutScreenDensity > 2000 )
    {
        return EScreenPhysicalAccuracy::Unknown;
    }

    OutScreenDensity *= GetWindowUpscaleFactor();
    return EScreenPhysicalAccuracy::Approximation;
#else
    return EScreenPhysicalAccuracy::Unknown;
#endif
}

18.14.3.3 FIOSPlatformApplicationMisc

FIOSPlatformApplicationMisc는 IOS 플랫폼 애플리케이션의 인터페이스를 구현합니다.

cpp
// IOSPlatformApplicationMisc.cpp

void FIOSPlatformApplicationMisc::LoadPreInitModules()
{
    FModuleManager::Get().LoadModule(TEXT("IOSAudio"));
    FModuleManager::Get().LoadModule(TEXT("AudioMixerAudioUnit"));
}

class FFeedbackContext* FIOSPlatformApplicationMisc::GetFeedbackContext()
{
    static FIOSFeedbackContext Singleton;
    return &Singleton;
}

class FOutputDeviceError* FIOSPlatformApplicationMisc::GetErrorOutputDevice()
{
    static FIOSErrorOutputDevice Singleton;
    return &Singleton;
}

// 생성(Create)적용 .
GenericApplication* FIOSPlatformApplicationMisc::CreateApplication()
{
    CachedApplication = FIOSApplication::CreateIOSApplication();
    return CachedApplication;
}

void FIOSPlatformApplicationMisc::EnableMotionData(bool bEnable)
{
    FIOSInputInterface* InputInterface = (FIOSInputInterface*)CachedApplication->GetInputInterface();
    return InputInterface->EnableMotionData(bEnable);
}

bool FIOSPlatformApplicationMisc::IsMotionDataEnabled()
{
    const FIOSInputInterface* InputInterface = (const FIOSInputInterface*)CachedApplication->GetInputInterface();
    return InputInterface->IsMotionDataEnabled();
}

// 전단
void FIOSPlatformApplicationMisc::ClipboardCopy(const TCHAR* Str)
{
#if !PLATFORM_TVOS
    CFStringRef CocoaString = FPlatformString::TCHARToCFString(Str);
    UIPasteboard* Pasteboard = [UIPasteboard generalPasteboard];
    [Pasteboard setString:(NSString*)CocoaString];
#endif
}

void FIOSPlatformApplicationMisc::ClipboardPaste(class FString& Result)
{
#if !PLATFORM_TVOS
    UIPasteboard* Pasteboard = [UIPasteboard generalPasteboard];
    NSString* CocoaString = [Pasteboard string];
    if(CocoaString)
    {
        TArray<TCHAR> Ch;
        Ch.AddUninitialized([CocoaString length] + 1);
        FPlatformString::CFStringToTCHAR((CFStringRef)CocoaString, Ch.GetData());
        Result = Ch.GetData();
    }
    else
    {
        Result = TEXT("");
    }
#endif
}

18.14.3.4 FLinuxPlatformApplicationMisc

FLinuxPlatformApplicationMisc는 Linux 플랫폼 애플리케이션의 인터페이스를 구현합니다.

cpp
// LinuxPlatformApplicationMisc.cpp

GenericApplication* FLinuxPlatformApplicationMisc::CreateApplication()
{
    return FLinuxApplication::CreateLinuxApplication();
}

void FLinuxPlatformApplicationMisc::PreInit()
{
    MessageBoxExtCallback = MessageBoxExtImpl;
    FApp::SetHasFocusFunction(&FLinuxPlatformApplicationMisc::IsThisApplicationForeground);
}

void FLinuxPlatformApplicationMisc::Init()
{
    // skip for servers and programs, unless they request later
    bool bIsNullRHI = !FApp::CanEverRender();
    if (!IS_PROGRAM && !bIsNullRHI)
    {
        InitSDL();
    }

    FGenericPlatformApplicationMisc::Init();

    UngrabAllInputCallback = UngrabAllInputImpl;
}

void FLinuxPlatformApplicationMisc::LoadPreInitModules()
{
#if WITH_EDITOR
    FModuleManager::Get().LoadModule(TEXT("OpenGLDrv"));
#endif // WITH_EDITOR
}

void FLinuxPlatformApplicationMisc::LoadStartupModules()
{
#if !IS_PROGRAM && !UE_SERVER
    FModuleManager::Get().LoadModule(TEXT("AudioMixerSDL"));    // added in Launch.Build.cs for non-server targets
    FModuleManager::Get().LoadModule(TEXT("HeadMountedDisplay"));
#endif // !IS_PROGRAM && !UE_SERVER

#if defined(WITH_STEAMCONTROLLER) && WITH_STEAMCONTROLLER
    FModuleManager::Get().LoadModule(TEXT("SteamController"));
#endif // WITH_STEAMCONTROLLER

#if WITH_EDITOR
    FModuleManager::Get().LoadModule(TEXT("SourceCodeAccess"));
#endif    //WITH_EDITOR
}

void FLinuxPlatformApplicationMisc::TearDown()
{
    FGenericPlatformApplicationMisc::TearDown();

    if (GInitializedSDL)
    {
        UE_LOG(LogInit, Log, TEXT("Tearing down SDL."));
        SDL_Quit();
        GInitializedSDL = false;

        MessageBoxExtCallback = nullptr;
        UngrabAllInputCallback = nullptr;
    }
}

// .
void FLinuxPlatformApplicationMisc::PumpMessages( bool bFromMainLoop )
{
    if (GInitializedSDL && bFromMainLoop)
    {
        if( LinuxApplication )
        {
            LinuxApplication->SaveWindowPropertiesForEventLoop();

            SDL_Event event;

            while (SDL_PollEvent(&event))
            {
                LinuxApplication->AddPendingEvent( event );
            }

            LinuxApplication->CheckIfApplicatioNeedsDeactivation();
            LinuxApplication->ClearWindowPropertiesAfterEventLoop();
        }
        else
        {
            // 이벤트 의 적용 , 오직 클리어 。
            SDL_Event event;
            while (SDL_PollEvent(&event))
            {
                // noop
            }
        }

        bool bHasFocus = FApp::HasFocus();

        // 만약 는 의 ,,이면 적용 .
        FApp::SetVolumeMultiplier( bHasFocus ? 1.0f : FApp::GetUnfocusedVolumeMultiplier() );
    }
}

18.14.3.5 F플랫폼애플리케이션기타

FPlatformApplicationMisc와 FGenericPlatformApplicationMisc 간의 관계, 구현 및 사용법은 FPlatformMisc 및 FGenericPlatformMisc와 유사하므로 다시 설명하지 않습니다.

18.14.4 프로세스, 스레드 및 동기화

18.14.4.1 FRunnableThread

FRunnableThread는 UE가 다양한 운영 체제에서 캡슐화하는 스레드 기본 클래스입니다. 그 정의는 다음과 같습니다:

cpp
// RunnableThread.h

class CORE_API FRunnableThread
{
public:
    // 생성(Create)
    static FRunnableThread* Create(FRunnable* InRunnable, const TCHAR* ThreadName, uint32 InStackSize = 0, ...);

    // 스레드(Thread)상태 변환(Transform/Convert).
    virtual void Suspend( bool bShouldPause = true ) = 0;
    virtual bool Kill( bool bShouldWait = true ) = 0;
    virtual void WaitForCompletion() = 0;

    // 스레드(Thread)
    
    // 스레드(Thread)타입/유형 .
    enum class ThreadType
    {
        Real,
        Fake,
        Forkable,
    };
    virtual FRunnableThread::ThreadType GetThreadType() const;
    static uint32 GetTlsSlot();
    virtual void SetThreadPriority( EThreadPriority NewPriority ) = 0;
    virtual bool SetThreadAffinity( const FThreadAffinity& Affinity );
    const uint32 GetThreadID() const;
    const FString& GetThreadName() const;
    EThreadPriority GetThreadPriority() const;

protected:
    void SetTls();
    void FreeTls();
    static FRunnableThread* GetRunnableThread();

private:
    static uint32 RunnableTlsSlot; // FRunnableThread의 TLS슬롯(Slot).
    
    static void SetupCreatedThread(...);
    virtual void Tick();
    virtual void OnPostFork();
    void PostCreate(EThreadPriority ThreadPriority);
    
    (...)
};

생성, 소멸, 상태 변환, 스택 크기 설정, 우선순위, 선호도, TLS 등의 인터페이스를 포함하여 UE가 스레드의 여러 인터페이스와 데이터를 추상화하는 것을 볼 수 있습니다. 여기서 상속받은 서브클래스는 각 플랫폼을 구현하는 클래스입니다. 상속 트리는 다음과 같습니다.

클래스 다이어그램-v2 FRunnableThread <|-- FRunnableThreadWin

FRunnableThread <|-- FRunnableThreadPThread FRunnableThreadPThread <|-- FRunnableThreadAndroid FRunnableThreadPThread <|-- FRunnableThreadApple FRunnableThreadPThread <|-- FRunnableThreadUnix

FRunnableThread <|-- FRunnableThreadHoloLens FRunnableThread <|-- FFakeThread

위 그림은 Windows가 FRunnableThread를 직접 상속하는 반면, Android, Apple, Unix 및 기타 시스템은 Unix 시스템의 POSIX 스레드(PThread) 메커니즘에서 파생되는 것을 보여줍니다.

POSIX 스레드 라이브러리는 다중 프로세서 또는 다중 코어 시스템에서 가장 효율적인 새로운 동시 프로세스 스트림을 생성할 수 있는 C/C++용 표준 기반 스레딩 API입니다. 이 시스템에서는 프로세스가 다른 프로세서에서 실행되도록 예약하여 병렬 또는 분산 처리를 통해 속도를 높일 수 있습니다. 스레드는 시스템이 프로세스에 대한 새로운 시스템 가상 메모리 공간과 환경을 초기화하지 않기 때문에 새 프로세스를 "포킹"하거나 생성하는 것보다 오버헤드가 적습니다.

다중 프로세서 시스템에서 가장 효과적이지만 I/O 대기 시간 및 프로세스 실행을 중단시킬 수 있는 기타 시스템 기능을 활용하는 단일 프로세서 시스템에서도 이점을 얻을 수 있습니다. (한 스레드는 다른 스레드가 I/O 또는 기타 시스템 지연을 기다리는 동안 실행될 수 있습니다.) MPI 및 PVM과 같은 병렬 프로그래밍 기술은 분산 컴퓨팅 환경에서 사용되는 반면 스레드는 단일 컴퓨터 시스템으로 제한됩니다. 프로세스의 모든 스레드는 동일한 주소 공간을 공유하며 스레드는 스레드에서 처리될 함수와 해당 매개변수를 정의하여 생성됩니다. 소프트웨어에서 POSIX 스레드 라이브러리를 사용하는 목적은 소프트웨어를 더 빠르게 실행하는 것입니다.

본질적으로 PThread는 프로세스이지만 일반 프로세스보다 가볍습니다. 경량 프로세스라고도 하며 스레드 시뮬레이션에 적합합니다. 이는 Unix 시스템에서만 발견되는 개념이며 Windows에는 존재하지 않습니다.

UE 시스템에서 FRunnableThread는 기본적인 크로스 플랫폼 스레드 기능만 제공합니다. 실제 애플리케이션에서는 ThreadManager, Runnable, Task, Queue, TaskGraph 및 기타 유형과 결합하여 완전한 동시성 시스템을 형성해야 합니다. 보다 자세한 기술적 내용은 2.4 UE의 멀티스레딩 메커니즘을 참조하시기 바랍니다. 이에 대해서는 이 글에서 다시 설명하지 않겠습니다.

18.14.4.2 F플랫폼프로세스

FPlatformProcess는 FGenericPlatformProcess의 유형을 재정의한 것입니다. 메커니즘은 FGenericPlatformMisc와 유사합니다. 이는 각 운영 체제의 프로세스를 캡슐화하고 나타냅니다. 다음은 FGenericPlatformProcess의 정의입니다:

cpp
// GenericPlatformProcess.h

struct CORE_API FGenericPlatformProcess
{
    // 신호(Signal)
    struct FSemaphore
    {
        const TCHAR* GetName() const;
        virtual void Lock() = 0;
        virtual bool TryLock(uint64 NanosecondsToWait) = 0;
        virtual void Unlock() = 0;

    protected:
        enum Limits
        {
            MaxSemaphoreName = 128
        };
        TCHAR Name[MaxSemaphoreName];
    };

    // dll/모듈
    static void* GetDllHandle( const TCHAR* Filename );
    static void FreeDllHandle( void* DllHandle );
    static void* GetDllExport( void* DllHandle, const TCHAR* ProcName );
    static void AddDllDirectory(const TCHAR* Directory);
    static void PushDllDirectory(const TCHAR* Directory);
    static void PopDllDirectory(const TCHAR* Directory);
    static void GetDllDirectories(TArray<FString>& OutDllDirectories);
    static const TCHAR* GetModulePrefix();
    static const TCHAR* GetModuleExtension();
    
    // /적용
    static FProcHandle CreateProc( const TCHAR* URL, const TCHAR* Parms, ...);
    static FProcHandle OpenProcess(uint32 ProcessID);
    static bool IsProcRunning( FProcHandle & ProcessHandle );
    static void WaitForProc( FProcHandle & ProcessHandle );
    static void CloseProc( FProcHandle & ProcessHandle );
    static void TerminateProc( FProcHandle & ProcessHandle, bool KillTree = false );
    static EWaitAndForkResult WaitAndFork();
    static bool GetProcReturnCode( FProcHandle & ProcHandle, int32* ReturnCode );
    static bool IsApplicationRunning( uint32 ProcessId );
    static bool IsApplicationRunning( const TCHAR* ProcName );
    static FString GetApplicationName( uint32 ProcessId );
    static bool GetApplicationMemoryUsage(uint32 ProcessId, SIZE_T* OutMemoryUsage);
    static bool ExecProcess(const TCHAR* URL, const TCHAR* Params,...);
    static bool ExecElevatedProcess(const TCHAR* URL, ...);
    static void LaunchURL( const TCHAR* URL, const TCHAR* Parms, FString* Error );
    static bool CanLaunchURL(const TCHAR* URL);
    static bool LaunchFileInDefaultExternalApplication( const TCHAR* FileName, ...);
    
    static void Sleep( float Seconds );
    static void SleepNoStats( float Seconds );
    static void SleepInfinite();
    static void YieldThread();
    static void Yield();
    static void YieldCycles(uint64 Cycles);
    static ENamedThreads::Type GetDesiredThreadForUObjectReferenceCollector();
    static void ModifyThreadAssignmentForUObjectReferenceCollector( int32& NumThreads, i... );
    static void ConditionalSleep(TFunctionRef<bool()> Condition, float SleepTime = 0.0f);
    
    static void SetRealTimeMode();
    static bool Daemonize();
    static bool IsFirstInstance();
    static void TearDown();
    static bool SkipWaitForStats();

    //
    static uint32 GetCurrentProcessId();
    static uint32 GetCurrentCoreNumber();
    static const TCHAR* ComputerName();
    static const TCHAR* UserName(bool bOnlyAlphaNumeric = true);
    static bool SetProcessLimits(EProcessResource::Type Resource, uint64 Limit);
    static const TCHAR* ExecutableName(bool bRemoveExtension = true);
    
    // 스레드(Thread)//동기화
    static void SetThreadAffinityMask( uint64 AffinityMask );
    static void SetThreadPriority( EThreadPriority NewPriority );
    static void SetThreadName( const TCHAR* ThreadName );
    static uint32 GetStackSize();
    static void DumpThreadInfo( const TCHAR* MarkerName );

    static void SetupGameThread();
    static void SetupRenderThread();
    static void SetupRHIThread();
    static void SetupAudioThread();
    static void TeardownAudioThread();
    
    static class FEvent* GetSynchEventFromPool(bool bIsManualReset = false);
    static void FlushPoolSyncEvents();
    static void ReturnSynchEventToPool(FEvent* Event);
    static class FRunnableThread* CreateRunnableThread();

    //
    static void ClosePipe( void* ReadPipe, void* WritePipe );
    static bool CreatePipe(void*& ReadPipe, void*& WritePipe, bool bWritePipeLocal = false);
    static FString ReadPipe( void* ReadPipe );
    static bool ReadPipeToArray(void* ReadPipe, TArray<uint8> & Output);
    static bool WritePipe(void* WritePipe, const FString& Message, FString* OutWritten = nullptr);
    static bool SupportsMultithreading();
    
    // (IPC)
    static FSemaphore* NewInterprocessSynchObject(const FString& Name, bool bCreate, uint32 MaxLocks = 1);
    static FSemaphore* NewInterprocessSynchObject(const TCHAR* Name, bool bCreate, uint32 MaxLocks = 1);
    static bool DeleteInterprocessSynchObject(FSemaphore * Object);
    
    // /
    static bool ShouldSaveToUserDir();
    static const TCHAR* BaseDir();
    static const TCHAR* UserDir();
    static const TCHAR *UserSettingsDir();
    static const TCHAR *UserTempDir();
    static const TCHAR *UserHomeDir();
    static const TCHAR* ApplicationSettingsDir();
    static const TCHAR* ShaderDir();
    static void SetShaderDir(const TCHAR*Where);
    static void SetCurrentWorkingDirectoryToBaseDir();
    static FString GetCurrentWorkingDirectory();
    static const FString ShaderWorkingDir();
    static void CleanShaderWorkingDir();
    static const TCHAR* ExecutablePath();
    static FString GenerateApplicationPath( const FString& AppName, EBuildConfiguration BuildConfiguration);
    static const TCHAR* GetBinariesSubdirectory();
    static const FString GetModulesDirectory();
    
    //
    static FString GetGameBundleId();
    static void ExploreFolder( const TCHAR* FilePath );
    
    (...)
};

위에서 볼 수 있듯이 UE의 프로세스 기반은 프로세스 수명주기, 애플리케이션 작업, 스레드 관리, 스레드 동기화, 프로세스 간 통신 및 동기화, 디렉터리 및 경로, DLL 모듈 등을 포함한 많은 인터페이스를 캡슐화합니다.

FGenericPlatformProcess의 상속 시스템과 구현은 FGenericPlatformMisc와 유사합니다. 이 기사에서는 이를 반복하지 않습니다. 관심 있는 어린이는 UE 소스 코드를 스스로 읽어볼 수 있습니다.

18.14.4.3 동기화 개체

다양한 크로스 스레드 및 크로스 프로세스 통신과 동기화를 충족하기 위해 UE는 많은 동기화 개체를 캡슐화합니다. 아래에서는 간단한 분석을 위해 몇 가지 중요한 유형을 추출합니다.

  • FCriticalSection 및 FSystemWideCriticalSection

FCriticalSection은 사용자 공간의 개념이자 메커니즘인 운영 체제의 임계 섹션 메커니즘을 사용하는 반면, FSystemWideCriticalSection은 운영 체제의 커널 개체 Mutex(뮤텍스)를 사용합니다. 일반적인 인터페이스는 다음과 같습니다(Windows를 예로 사용).

cpp
// WindowsCriticalSection.h

class FWindowsCriticalSection
{
public:
    void Lock();
    bool TryLock();
    void Unlock();
    
    (...)
};

class FWindowsSystemWideCriticalSection
{
public:
    bool IsValid() const;
    void Release();

private:
    // 을(를) 활용하여 Windows의 Mutex객체/오브젝트 구현 .
    Windows::HANDLE Mutex;
    
    (...)
};

FCriticalSection은 사용자 상태와 커널 상태 간의 변환을 포함하지 않으며 더 효율적이지만 프로세스 간이 아닌 스레드 간 동기화에만 사용할 수 있습니다.

FSystemWideCriticalSection에는 사용자 상태와 커널 상태 간의 변환이 포함됩니다. 효율성은 FCriticalSection보다 훨씬 낮지만 프로세스 간 동기화에 사용할 수 있습니다.

  • FRW잠금

FRWLock은 비재귀적 읽기/쓰기(또는 공유 배타적) 액세스를 제공하며 종종 여러 스레드에서 동일한 데이터 블록에 액세스하는 데 사용됩니다. 특이한 점은 읽기만 하는 경우 여러 스레드가 동시에 읽을 수 있지만, 한 스레드가 쓰는 경우에는 해당 스레드가 데이터 블록을 독점적으로 점유해야 하며, 쓰기가 완료된 후에야 다른 스레드가 읽거나 쓰는 것이 허용된다는 점입니다. 그 정의는 다음과 같습니다(Windows를 예로 들어):

cpp
// WindowsCriticalSection.h

class FWindowsRWLock
{
public:
    FWindowsRWLock(uint32 Level = 0);
    ~FWindowsRWLock();
    
    void ReadLock();
    void WriteLock();
    void ReadUnlock();
    void WriteUnlock();
    
private:
    Windows::SRWLOCK Mutex;
};
  • FPlatformAtomics

FPlatformAtomics는 각 운영 체제의 원자적 작업을 캡슐화합니다. 인터페이스는 다음과 같습니다(Windows를 예로 들어).

cpp
// GenericPlatformAtomics.h

struct CORE_API FWindowsPlatformAtomics : public FGenericPlatformAtomics
{
    // Increment
    static int8 InterlockedIncrement( volatile int8* Value );
    static int16 InterlockedIncrement( volatile int16* Value );
    static int32 InterlockedIncrement( volatile int32* Value );
    static int64 InterlockedIncrement( volatile int64* Value );
    
    // Decrement
    static int8 InterlockedDecrement( volatile int8* Value );
    static int16 InterlockedDecrement( volatile int16* Value );
    static int32 InterlockedDecrement( volatile int32* Value );
    static int64 InterlockedDecrement( volatile int64* Value );
    
    // Add
    static int8 InterlockedAdd( volatile int8* Value, int8 Amount );
    static int16 InterlockedAdd( volatile int16* Value, int16 Amount );
    static int32 InterlockedAdd( volatile int32* Value, int32 Amount );
    static int64 InterlockedAdd( volatile int64* Value, int64 Amount );
    
    // Exchange
    static int8 InterlockedExchange( volatile int8* Value, int8 Exchange );
    static int16 InterlockedExchange( volatile int16* Value, int16 Exchange );
    static int32 InterlockedExchange( volatile int32* Value, int32 Exchange );
    static int64 InterlockedExchange( volatile int64* Value, int64 Exchange );
    static void* InterlockedExchangePtr( void*volatile* Dest, void* Exchange );
    static int8 InterlockedCompareExchange( volatile int8* Dest, int8 Exchange, int8 Comparand );
    static int16 InterlockedCompareExchange( volatile int16* Dest, int16 Exchange, int16 Comparand );
    static int32 InterlockedCompareExchange( volatile int32* Dest, int32 Exchange, int32 Comparand );
    static int64 InterlockedCompareExchange( volatile int64* Dest, int64 Exchange, int64 Comparand );
    
    // And
    static int8 InterlockedAnd(volatile int8* Value, const int8 AndValue);
    static int16 InterlockedAnd(volatile int16* Value, const int16 AndValue);
    static int32 InterlockedAnd(volatile int32* Value, const int32 AndValue);
    static int64 InterlockedAnd(volatile int64* Value, const int64 AndValue);
    
    // Or / Xor
    static int8 InterlockedOr(volatile int8* Value, const int8 OrValue);
    static int16 InterlockedOr(volatile int16* Value, const int16 OrValue);
    static int32 InterlockedOr(volatile int32* Value, const int32 OrValue);
    static int64 InterlockedOr(volatile int64* Value, const int64 OrValue);
    static int8 InterlockedXor(volatile int8* Value, const int8 XorValue);
    static int16 InterlockedXor(volatile int16* Value, const int16 XorValue);
    static int32 InterlockedXor(volatile int32* Value, const int32 XorValue);
    static int64 InterlockedXor(volatile int64* Value, const int64 XorValue);
    
    // Read
    static int8 AtomicRead(volatile const int8* Src);
    static int16 AtomicRead(volatile const int16* Src);
    static int32 AtomicRead(volatile const int32* Src);
    static int64 AtomicRead(volatile const int64* Src);
    
    // Read Relaxed
    static int8 AtomicRead_Relaxed(volatile const int8* Src);
    static int16 AtomicRead_Relaxed(volatile const int16* Src);
    static int32 AtomicRead_Relaxed(volatile const int32* Src);
    static int64 AtomicRead_Relaxed(volatile const int64* Src);
    
    // Store
    static void AtomicStore(volatile int8* Src, int8 Val);
    static void AtomicStore(volatile int16* Src, int16 Val);
    static void AtomicStore(volatile int32* Src, int32 Val);
    static void AtomicStore(volatile int64* Src, int64 Val);
    
    // Store Relaxed
    static void AtomicStore_Relaxed(volatile int8* Src, int8 Val);
    static void AtomicStore_Relaxed(volatile int16* Src, int16 Val);
    static void AtomicStore_Relaxed(volatile int32* Src, int32 Val);
    static void AtomicStore_Relaxed(volatile int64* Src, int64 Val);

#if    PLATFORM_HAS_128BIT_ATOMICS
    static bool InterlockedCompareExchange128( volatile FInt128* Dest, const FInt128& Exchange, FInt128* Comparand );
    static void AtomicRead128(const volatile FInt128* Src, FInt128* OutResult);
#endif
    static void* InterlockedCompareExchangePointer( void*volatile* Dest, void* Exchange, void* Comparand );
    static bool CanUseCompareExchange128();

protected:
    static void HandleAtomicsFailure( const TCHAR* InFormat, ... );
};
  • 플랫폼TLS

TLS의 정식 명칭은 Thread Local Storage이며, 이는 스레드 로컬 저장소를 의미합니다. 이름에서 알 수 있듯이 각 스레드마다 고유한 데이터를 저장할 수 있으므로 여러 스레드 간의 경쟁을 피하고 성능을 향상시킬 수 있습니다.

UE는 상위 계층 모듈에 통일되고 간결한 TLS 운영 인터페이스를 제공하기 위해 FPlatformTLS를 제공합니다. 그 정의는 다음과 같습니다(Windows를 예로 들어):

cpp
// WindowsPlatformTLS.h

struct FWindowsPlatformTLS : public FGenericPlatformTLS
{
    // TLS의 스레드(Thread)id。
    static uint32 GetCurrentThreadId(void);
    
    // TLS의 슬롯(Slot)。
    static uint32 AllocTlsSlot(void);
    static void FreeTlsSlot(uint32 SlotIndex);
    
    // TLS。
    static void SetTlsValue(uint32 SlotIndex,void* Value);
    static void* GetTlsValue(uint32 SlotIndex);
};

한 가지 사용 예는 LockFreeList 구현입니다.

cpp
// LockFreeList.cpp

class LockFreeLinkAllocator_TLSCache : public FNoncopyable
{
public:
    LockFreeLinkAllocator_TLSCache()
    {
        check(IsInGameThread());
        TlsSlot = FPlatformTLS::AllocTlsSlot();
        check(FPlatformTLS::IsValidTlsSlot(TlsSlot));
    }
    
    ~LockFreeLinkAllocator_TLSCache()
    {
        FPlatformTLS::FreeTlsSlot(TlsSlot);
        TlsSlot = 0;
    }

private:
    FThreadLocalCache& GetTLS()
    {
        checkSlow(FPlatformTLS::IsValidTlsSlot(TlsSlot));
        FThreadLocalCache* TLS = (FThreadLocalCache*)FPlatformTLS::GetTlsValue(TlsSlot);
        if (!TLS)
        {
            TLS = new FThreadLocalCache();
            FPlatformTLS::SetTlsValue(TlsSlot, TLS);
        }
        return *TLS;
    }
    
    uint32 TlsSlot;
    
    (...)
};
  • FPlatformNamedPipe

FPlatformNamedPipe는 운영 체제의 명명된 파이프 통신을 캡슐화하고 다음 인터페이스를 제공합니다.

cpp
// GenericPlatformNamedPipe.h

class FGenericPlatformNamedPipe
{
public:
    virtual bool Create(const FString& PipeName, bool bServer, bool bAsync);
    virtual bool Destroy();
    
    virtual bool OpenConnection();
    virtual bool BlockForAsyncIO();
    virtual bool UpdateAsyncStatus();
    
    virtual bool IsCreated() const;
    virtual bool HasFailed() const;
    virtual bool IsReadyForRW() const;
    
    virtual bool WriteBytes(int32 NumBytes, const void* Data);
    inline bool WriteInt32(int32 In);
    virtual bool ReadBytes(int32 NumBytes, void* OutData);
    inline bool ReadInt32(int32& Out);
    
    virtual const FString& GetName() const;

protected:
    FString* NamePtr;
};

UE가 FPlatformNamedPipe를 사용하는 것은 셰이더 컴파일 모듈입니다:

cpp
// XGEControlWorker.cpp

class FXGEControlWorker
{
    const FString PipeName;
    FProcHandle XGConsoleProcHandle;

    // 입력、출력。
    FPlatformNamedPipe InputNamedPipe;
    FPlatformNamedPipe OutputNamedPipe;

    (...)
};
  • FPlatformStackWalk

FPlatformStackWalk는 대부분의 플랫폼에서 일반적인 스택 탐색 구현이며 다음과 같이 정의됩니다.

cpp
// GenericPlatformStackWalk.h

struct FGenericPlatformStackWalk
{
    // 초기화(Initialize)
    static void Init();
    static bool InitStackWalking();
    static bool InitStackWalkingForProcess(const FProcHandle& Process);
    
    //
    static bool ProgramCounterToHumanReadableString( int32 CurrentCallDepth, ... );
    static void ProgramCounterToSymbolInfo( uint64 ProgramCounter, ...);
    static void ProgramCounterToSymbolInfoEx( uint64 ProgramCounter, ...);
    
    // 정보
    static bool SymbolInfoToHumanReadableString( const FProgramCounterSymbolInfo& SymbolInfo, ... );
    static bool SymbolInfoToHumanReadableStringEx( const FProgramCounterSymbolInfoEx& SymbolInfo, ... );
    static TArray<FProgramCounterSymbolInfo> GetStack(int32 IgnoreCount, ...);
    
    // 힙
    static uint32 CaptureStackBackTrace( uint64* BackTrace, uint32 MaxDepth, ... );
    static uint32 CaptureThreadStackBackTrace(uint64 ThreadId, ...);
    
    // 순회(Traverse/Iterate)
    static void StackWalkAndDump( ANSICHAR* HumanReadableString, ... );
    static void ThreadStackWalkAndDump(ANSICHAR* HumanReadableString, ...);
    static void StackWalkAndDumpEx( ANSICHAR* HumanReadableString, ... );
    
    // 페치/가져오기(Fetch)인터페이스 .
    static int32 GetProcessModuleCount();
    static int32 GetProcessModuleSignatures(FStackWalkModuleInfo *ModuleSignatures, ...);
    static TMap<FName, FString> GetSymbolMetaData();

    (...)
};

FPlatformStackWalk의 애플리케이션 중 하나는 프로그램 충돌 시 호출 스택 인쇄 및 분석입니다.

cpp
// CrashReportClientMainWindows.cpp

void SaveCrcCrashException(EXCEPTION_POINTERS* ExceptionInfo)
{
    // 만약 생성(Create),의 내에서 기록/쓰기(Write)코드 。의 개 스레드(Thread),로써 코드 。
    static volatile int32 CrashCount = 0;
    if (FPlatformAtomics::InterlockedIncrement(&CrashCount) == 1)
    {
        FCrashReportAnalyticsSessionSummary::Get().OnCrcCrashing(ExceptionInfo->ExceptionRecord->ExceptionCode);

        if (ExceptionInfo->ExceptionRecord->ExceptionCode != STATUS_HEAP_CORRUPTION)
        {
            // 호출(Call)힙 ,로써 CRC의 ,그러나 ,이므로 의 내에서 ,할당(Allocate)메모리 /을(를) 활용하여 호출(Call)힙 ,그러나 로써 을(를) 활용하여 의 데이터 。
            if (FPlatformStackWalk::InitStackWalkingForProcess(FProcHandle()))
            {
                FPlatformStackWalk::StackWalkAndDump(CrashStackTrace, UE_ARRAY_COUNT(CrashStackTrace), 0);
                if (CrashStackTrace[0] != 0)
                {
                    FCrashReportAnalyticsSessionSummary::Get().LogEvent(ANSI_TO_TCHAR(CrashStackTrace));
                }
            }
        }
    }
}

18.14.5 기타 플랫폼 모듈

18.14.5.1 F플랫폼메모리

FPlatformMemory는 다양한 운영 체제에서 메모리에 대한 통합 운영 인터페이스를 캡슐화합니다.

cpp
// GenericPlatformMemory.h

struct FGenericPlatformMemory
{
    static bool bIsOOM; // 는 메모리
    static uint64 OOMAllocationSize; // 설정(Set)로 트리거 메모리 의 할당(Allocate)크기 ,이면 로 .
    static uint32 OOMAllocationAlignment; // 설정(Set)로 트리거 메모리 의 페어링(Pairing),이면 로 。
    static void* BackupOOMMemoryPool; // 메모리 의 할당(Allocate)버퍼 。을(를) 활용하여 OOM처리(Process) 및 。
    static uint32 BackupOOMMemoryPoolSize; // BackupOOMMemoryPool의 크기 ()。

    // 을(를) 활용하여 메모리 의 메모리 。의 플랫폼,의 (、GPU)。개 플랫폼로써 추가(Add)의 메모리 ,플랫폼,StatManager개 의 을(를) 활용하여 메모리 (을(를) 활용하여 배열 FPlatformMemory::MCR_max big)의 메모리 .
    enum EMemoryCounterRegion
    {
        MCR_Invalid, // not memory
        MCR_Physical, // main system memory
        MCR_GPU, // memory directly a GPU (graphics card, etc)
        MCR_GPUSystem, // system memory directly accessible by a GPU
        MCR_TexturePool, // presized texture pools
        MCR_StreamingPool, // amount of texture pool available for streaming.
        MCR_UsedStreamingPool, // amount of texture pool used for streaming.
        MCR_GPUDefragPool, // presized pool of memory that can be defragmented.
        MCR_PhysicalLLM, // total physical memory including CPU and GPU
        MCR_MAX
    };

    // 을(를) 활용하여 의 할당(Allocate).
    enum EMemoryAllocatorToUse
    {
        Ansi, // Default C allocator
        Stomp, // Allocator to check for memory stomping
        TBB, // Thread Building Blocks malloc
        Jemalloc, // Linux/FreeBSD malloc
        Binned, // Older binned malloc
        Binned2, // Newer binned malloc
        Binned3, // Newer VM-based binned malloc, 64 bit only
        Platform, // Custom platform specific allocator
        Mimalloc, // mimalloc
    };
    static EMemoryAllocatorToUse AllocatorToUse;

    enum ESharedMemoryAccess
    {
        Read    =        (1 << 1),
        Write    =        (1 << 2)
    };

    // 메모리 의 을(를) 활용하여 테이블
    struct FSharedMemoryRegion
    {
        TCHAR    Name[MaxSharedMemoryName];
        uint32   AccessMode;
        void *   Address;
        SIZE_T   Size;
    };

    // 메모리 .
    static void Init();
    static void OnOutOfMemory(uint64 Size, uint32 Alignment);
    static void SetupMemoryPools();
    static uint32 GetBackMemoryPoolSize()
    static FMalloc* BaseAllocator();
    static FPlatformMemoryStats GetStats();
    static uint64 GetMemoryUsedFast();
    static void GetStatsForMallocProfiler( FGenericMemoryStats& out_Stats );
    static const FPlatformMemoryConstants& GetConstants();
    static uint32 GetPhysicalGBRam();

    static bool PageProtect(void* const Ptr, const SIZE_T Size, const bool bCanRead, const bool bCanWrite);
    
    // 할당(Allocate).
    static void* BinnedAllocFromOS( SIZE_T Size );
    static void BinnedFreeToOS( void* Ptr, SIZE_T Size );
    static void NanoMallocInit();
    static bool PtrIsOSMalloc( void* Ptr);
    static bool IsNanoMallocAvailable();
    static bool PtrIsFromNanoMalloc( void* Ptr);

    // 메모리 및 .
    class FBasicVirtualMemoryBlock
    {
    protected:
        void *Ptr;
        uint32 VMSizeDivVirtualSizeAlignment;

    public:
        FBasicVirtualMemoryBlock(const FBasicVirtualMemoryBlock& Other) = default;
        FBasicVirtualMemoryBlock& operator=(const FBasicVirtualMemoryBlock& Other) = default;
        FORCEINLINE uint32 GetActualSizeInPages() const;
        FORCEINLINE void* GetVirtualPointer() const;

        void Commit(size_t InOffset, size_t InSize);
        void Decommit(size_t InOffset, size_t InSize);
        void FreeVirtual();

        void CommitByPtr(void *InPtr, size_t InSize);
        void DecommitByPtr(void *InPtr, size_t InSize);
        void Commit();
        void Decommit();
        size_t GetActualSize() const;

        static FPlatformVirtualMemoryBlock AllocateVirtual(size_t Size, ...);
        
        static size_t GetCommitAlignment();
        static size_t GetVirtualSizeAlignment();

    };

    // 데이터 및
    static bool BinnedPlatformHasMemoryPoolForThisSize(SIZE_T Size);
    static void DumpStats( FOutputDevice& Ar );
    static void DumpPlatformAndAllocatorStats( FOutputDevice& Ar );

    static EPlatformMemorySizeBucket GetMemorySizeBucket();

    // 메모리 데이터 .
    static void* Memmove( void* Dest, const void* Src, SIZE_T Count );
    static int32 Memcmp( const void* Buf1, const void* Buf2, SIZE_T Count );
    static void* Memset(void* Dest, uint8 Char, SIZE_T Count);
    static void* Memzero(void* Dest, SIZE_T Count);
    static void* Memcpy(void* Dest, const void* Src, SIZE_T Count);
    static void* BigBlockMemcpy(void* Dest, const void* Src, SIZE_T Count);
    static void* StreamingMemcpy(void* Dest, const void* Src, SIZE_T Count);
    static void* ParallelMemcpy(void* Dest, const void* Src, SIZE_T Count, EMemcpyCachePolicy Policy = EMemcpyCachePolicy::StoreCached);

    (...)
};

자세한 내용은 1.4.3 메모리 할당을 참조하세요.

18.14.5.2 FPlatformMath

FPlatformMath는 다음과 같이 정의된 운영 체제에 따라 효율적인 수학 연산 세트를 캡슐화합니다.

cpp
// GenericPlatformMath.h

struct FGenericPlatformMath
{
    static float LoadHalf(const uint16* Ptr);
    static void StoreHalf(uint16* Ptr, float Value);
    static void VectorLoadHalf(float* RESTRICT Dst, const uint16* RESTRICT Src);
    static void VectorStoreHalf(uint16* RESTRICT Dst, const float* RESTRICT Src);
    static void WideVectorLoadHalf(float* RESTRICT Dst, const uint16* RESTRICT Src);
    static void WideVectorStoreHalf(uint16* RESTRICT Dst, const float* RESTRICT Src);
    
    static inline uint32 AsUInt(float F);
    static inline uint64 AsUInt(double F);
        
    static int32 TruncToInt(float F);
    static int32 TruncToInt(double F);
    static float TruncToFloat(float F);

    static int32 FloorToInt(float F);
    static int32 FloorToInt(double F);
    static float FloorToFloat(float F);
    
    static int32 RoundToInt(float F);
    static int32 RoundToInt(double F);
    static float RoundToFloat(float F);
    
    static int32 CeilToInt(float F);
    static int32 CeilToInt(double F);
    static float CeilToFloat(float F);
        
    static float Fractional(float Value);
    static float Frac(float Value);
    static float Modf(const float InValue, float* OutIntPart);

    static float Pow( float A, float B );
    static float Exp( float Value );
    static float Loge( float Value );
    static float LogX( float Base, float Value );
    static uint32 FloorLog2(uint32 Value);
    
    static float Sqrt( float Value );
    static float InvSqrt( float F );
    static float InvSqrtEst( float F );
    
    static bool IsNaN( float A );
    static bool IsNaN(double A);
    static bool IsFinite( float A );
    static bool IsFinite(double A);
    static bool IsNegative(float A);
    
    static float Fmod(float X, float Y);
    static float Sin( float Value );
    static float Asin( float Value );
    static float Sinh(float Value);
    static float Cos( float Value );
    static float Acos( float Value );
    static float Tan( float Value );
    static float Atan( float Value );
    
    static int32 Rand();
    static void RandInit(int32 Seed);
    static float FRand();
    static void SRandInit( int32 Seed );
    static int32 GetRandSeed();
    static float SRand();
    
    (...)
};

18.14.5.3 IPlatform파일

IFileHandle은 각 시스템 아래의 단일 파일에 대한 UE의 캡슐화이고, IPlatformFile은 각 시스템 아래의 UE의 파일 캡슐화이며, FPlatformFileManager는 IPlatformFile 체인의 관리입니다.

먼저 IFileHandle의 핵심 상속 다이어그램을 살펴보겠습니다.

클래스 다이어그램-v2 IFileHandle <|-- FFileHandleAndroid IFileHandle <|-- FFileHandleWindows IFileHandle <|-- FFileHandleApple IFileHandle <|-- FIOSFileHandle IFileHandle <|-- FFileHandleHoloLens IFileHandle <|-- FCachedFileHandle

위 그림에 표시된 파일 형식 외에도 FAsyncBufferedFileReaderWindows, FLoggedFileHandle, FManagedStorageFileWriteHandle, FRegisteredFileHandle, FNetworkFileHandle, FStreamingNetworkFileHandle, FPakFileHandle, FStorageServerFileHandle 등과 같은 파일 형식도 있습니다.

IPlatformFile의 핵심 UML 다이어그램을 살펴보겠습니다.

클래스 다이어그램-v2 IPlatformFile <|-- IPhysicalPlatformFile IPhysicalPlatformFile <|-- FHoloLensPlatformFile IPhysicalPlatformFile <|-- FWindowsPlatformFile IPhysicalPlatformFile <|-- IAndroidPlatformFile IAndroidPlatformFile <|-- FAndroidPlatformFile IPhysicalPlatformFile <|-- FApplePlatformFile FApplePlatform파일 <|-- FIOSPlatform파일 IPhysicalPlatformFile <|-- FUnixPlatformFile

물론 IPlatformFile에서 상속되는 색다른 파일 형식이 많이 있습니다.

  • FCachedReadPlatformFile: 캐시 파일입니다.

  • FLoggedPlatformFile: 로그 파일.

  • FPlatformFileOpenLog: 로그 파일을 엽니다.

  • FNetworkPlatformFile: 네트워크 파일.

  • FPakPlatformFile: Pak 패키지 내부의 파일입니다.

  • FSandboxPlatformFile: 샌드박스 파일.

  • FStorageServerPlatformFile: 서버 파일을 저장합니다.

  • FConcertSandboxPlatformFile: 콘서트 샌드박스 파일.

IFileHandle 및 IPlatformFile의 정의를 살펴보겠습니다.

cpp
// GenericPlatformFile.h

class IFileHandle
{
public:
    virtual int64 Tell() = 0;
    virtual bool  Seek(int64 NewPosition) = 0;
    virtual bool  SeekFromEnd(int64 NewPositionRelativeToEnd = 0) = 0;
    virtual bool  Read(uint8* Destination, int64 BytesToRead) = 0;
    virtual bool  Write(const uint8* Source, int64 BytesToWrite) = 0;
    virtual bool  Flush(const bool bFullFlush = false) = 0;
    virtual bool  Truncate(int64 NewSize) = 0;
    virtual void  ShrinkBuffers()
    virtual int64 Size();
};

class IPlatformFile
{
public:
    static IPlatformFile& GetPlatformPhysical();
    static const TCHAR* GetPhysicalTypeName();

    virtual void SetSandboxEnabled(bool bInEnabled)
    virtual bool IsSandboxEnabled() const

    virtual bool ShouldBeUsed(IPlatformFile* Inner, const TCHAR* CmdLine) const
    virtual bool Initialize(IPlatformFile* Inner, const TCHAR* CmdLine) = 0;
    virtual void InitializeAfterSetActive()
    virtual void InitializeAfterProjectFilePath()
    virtual void MakeUniquePakFilesForTheseFiles(const TArray<TArray<FString>>& InFiles)
    virtual void InitializeNewAsyncIO();
    virtual void AddLocalDirectories(TArray<FString> &LocalDirectories)
    virtual void BypassSecurity(bool bInBypass)
        
    virtual void Tick();
    
    virtual IPlatformFile* GetLowerLevel() = 0;
    virtual void           SetLowerLevel(IPlatformFile* NewLowerLevel) = 0;
    virtual const TCHAR*   GetName() const = 0;
    
    virtual bool        FileExists(const TCHAR* Filename) = 0;
    virtual int64        FileSize(const TCHAR* Filename) = 0;
    virtual bool        DeleteFile(const TCHAR* Filename) = 0;
    virtual bool        IsReadOnly(const TCHAR* Filename) = 0;
    virtual bool        MoveFile(const TCHAR* To, const TCHAR* From) = 0;
    virtual bool        SetReadOnly(const TCHAR* Filename, bool bNewReadOnlyValue) = 0;
    virtual FDateTime    GetTimeStamp(const TCHAR* Filename) = 0;
    virtual void        SetTimeStamp(const TCHAR* Filename, FDateTime DateTime) = 0;
    virtual FDateTime    GetAccessTimeStamp(const TCHAR* Filename) = 0;
    virtual FString     GetFilenameOnDisk(const TCHAR* Filename) = 0;

    virtual IFileHandle* OpenRead(const TCHAR* Filename, bool bAllowWrite = false) = 0;
    virtual IFileHandle* OpenReadNoBuffering(const TCHAR* Filename, bool bAllowWrite = false)
    virtual IFileHandle* OpenWrite(const TCHAR* Filename, bool bAppend = false, bool bAllowRead = false) = 0;

    virtual bool        DirectoryExists(const TCHAR* Directory) = 0;
    virtual bool        CreateDirectory(const TCHAR* Directory) = 0;
    virtual bool        DeleteDirectory(const TCHAR* Directory) = 0;

    virtual FFileStatData GetStatData(const TCHAR* FilenameOrDirectory) = 0;

    // 을(를) 활용하여 의 및 의 。
    class FDirectoryVisitor
    {
    public:
        virtual bool Visit(const TCHAR* FilenameOrDirectory, bool bIsDirectory) = 0;
        FORCEINLINE bool IsThreadSafe() const
        EDirectoryVisitorFlags DirectoryVisitorFlags;
    };

    typedef TFunctionRef<bool(const TCHAR*, bool)> FDirectoryVisitorFunc;

    // 페치/가져오기(Fetch)데이터 의 및 의 .
    class FDirectoryStatVisitor
    {
    public:
        virtual bool Visit(const TCHAR* FilenameOrDirectory, const FFileStatData& StatData) = 0;
    };

    typedef TFunctionRef<bool(const TCHAR*, const FFileStatData&)> FDirectoryStatVisitorFunc;
    virtual bool        IterateDirectory(const TCHAR* Directory, FDirectoryVisitor& Visitor) = 0;
    virtual bool        IterateDirectoryStat(const TCHAR* Directory, FDirectoryStatVisitor& Visitor) = 0;

    virtual IAsyncReadFileHandle* OpenAsyncRead(const TCHAR* Filename);
    virtual void SetAsyncMinimumPriority(EAsyncIOPriorityAndFlags MinPriority)

    virtual IMappedFileHandle* OpenMapped(const TCHAR* Filename)

    virtual void GetTimeStampPair(const TCHAR* PathA, const TCHAR* PathB, FDateTime& OutTimeStampA, FDateTime& OutTimeStampB);
    virtual FDateTime    GetTimeStampLocal(const TCHAR* Filename);

    virtual bool IterateDirectory(const TCHAR* Directory, FDirectoryVisitorFunc Visitor);
    virtual bool IterateDirectoryStat(const TCHAR* Directory, FDirectoryStatVisitorFunc Visitor);
    virtual bool IterateDirectoryRecursively(const TCHAR* Directory, FDirectoryVisitor& Visitor);
    virtual bool IterateDirectoryStatRecursively(const TCHAR* Directory, FDirectoryStatVisitor& Visitor);
    virtual bool IterateDirectoryRecursively(const TCHAR* Directory, FDirectoryVisitorFunc Visitor);
    virtual bool IterateDirectoryStatRecursively(const TCHAR* Directory, FDirectoryStatVisitorFunc Visitor);
    virtual void FindFiles(TArray<FString>& FoundFiles, const TCHAR* Directory, const TCHAR* FileExtension);
    virtual void FindFilesRecursively(TArray<FString>& FoundFiles, const TCHAR* Directory, const TCHAR* FileExtension);
    virtual bool DeleteDirectoryRecursively(const TCHAR* Directory);
    virtual bool CreateDirectoryTree(const TCHAR* Directory);
    virtual bool CopyFile(const TCHAR* To, const TCHAR* From, EPlatformFileRead ReadFlags = EPlatformFileRead::None, EPlatformFileWrite WriteFlags = EPlatformFileWrite::None);
    virtual bool CopyDirectoryTree(const TCHAR* DestinationDirectory, const TCHAR* Source, bool bOverwriteAllExisting);
    virtual FString ConvertToAbsolutePathForExternalAppForRead( const TCHAR* Filename );
    virtual FString ConvertToAbsolutePathForExternalAppForWrite( const TCHAR* Filename );

    // 을(를) 활용하여 함수 /데이터 의 .
    class IFileServerMessageHandler
    {
    public:
        virtual ~IFileServerMessageHandler() { }
        virtual void FillPayload(FArchive& Payload) = 0;
        virtual void ProcessResponse(FArchive& Response) = 0;
    };

    virtual bool SendMessageToServer(const TCHAR* Message, IFileServerMessageHandler* Handler)
    virtual bool DoesCreatePublicFiles()
    virtual void SetCreatePublicFiles(bool bCreatePublicFiles)
};

IFileHandle, IPlatformFile 및 FPlatformFileManager의 UML 관계는 다음과 같습니다(해당 하위 클래스 무시).

클래스 다이어그램-v2 IPlatformFile --o IFileHandle FPlatformFileManager --oIPlatform파일

FPlatformFileManager는 다음과 같이 정의된 외부 세계에 대한 플랫폼 독립적인 파일 작업 인터페이스를 제공합니다.

cpp
// PlatformFileManager.h

class FPlatformFileManager
{
public:
    static FPlatformFileManager& Get( );
    
    IPlatformFile& GetPlatformFile( );
    IPlatformFile* GetPlatformFile( const TCHAR* Name );
    IPlatformFile* FindPlatformFile( const TCHAR* Name );
    void SetPlatformFile( IPlatformFile& NewTopmostPlatformFile );
    
    void TickActivePlatformFile();
    void InitializeNewAsyncIO();
    void RemovePlatformFile(IPlatformFile* PlatformFileToRemove);
    
private:
    IPlatformFile* TopmostPlatformFile;
};

사용 사례는 다음과 같습니다.

cpp
// CrashReportClientApp.cpp

static void HandleAbnormalShutdown(FSharedCrashContext& CrashContext, uint64 ProcessID, ...)
{
    (...)

    // 페치/가져오기(Fetch)플랫폼객체/오브젝트 。
    IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();

    // 생성(Create)
    const FString TempCrashDirectory = FPlatformProcess::UserTempDir() / FString::Printf(TEXT("UECrashContext-%d"), ProcessID);
    FCString::Strcpy(CrashContext.CrashFilesDirectory, *TempCrashDirectory);

    if (PlatformFile.CreateDirectory(CrashContext.CrashFilesDirectory))
    {
        // 까지
        const FString LogDestination = TempCrashDirectory / FPaths::GetCleanFilename(CrashContext.UserSettings.LogFilePath);
        PlatformFile.CopyFile(*LogDestination, CrashContext.UserSettings.LogFilePath);

        // 는 의 ,는 의 。
        FCrashReportAnalyticsSessionSummary::Get().LogEvent(TEXT("SyntheticCrash"));

        (...)

        // 。
        PlatformFile.DeleteDirectoryRecursively(*TempCrashDirectory);

        (...)
    }
}

18.15 이 기사 요약

이 글에서는 주로 스레드, 프로세스, 동기화, 통신, 메모리, 디스크 등 운영체제 관련 지식과 UE의 OS 관련 모듈 캡슐화 및 구현을 설명하여 독자들이 운영체제 모듈에 대한 전반적인 이해를 가질 수 있도록 돕습니다. 보다 기술적인 세부 사항과 원리에 관해서는 독자들이 UE 소스 코드를 직접 연구해야 합니다.

특별 지침

  • 모든 참고문헌의 저자에게 감사드립니다. 일부 사진은 참고 자료와 인터넷에서 가져온 것이므로 삭제되었습니다.

  • 이 시리즈 기사는 저자가 직접 작성한 것이며 Blog Park 및 Zhihu에만 게시됩니다. 이 글의 링크를 공유하는 것은 환영하지만 동의 없는 재인쇄는 허용되지 않습니다!

  • 계속되는 일련의 기사. 전체 목록을 보려면 언리얼 렌더링 시스템 분석 - 오프닝 노트를 클릭하세요.

  • 블로거들의 루머에 대한 '큰 공장'의 '기술 전문가'의 반응에 대해








📚 참고 자료 및 공식 링크 (References)


🔗 연관 지식 베이스 (Wiki & Tools)

Based on the legendary technical series by Timlly (0向往0 / 毛星云)