반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.5의 내용을 읽고 요약한 글입니다. 

 

6.5 mmap: 메모리 읽기와 쓰기 방식으로 파일 처리

코드에서 메모리를 읽기 쓰는 것은 아래와 같이 배열을 정의하고 값을 할당하기만 하면 됨

int a[100];
a[10] = 2;

하지만 파일을 읽는 경우는 상대적으로 더 복잡

char buf[1024];

int fd = open("/filepath/abc.txt");
read(fd, buf, 1024);
// buf 등을 이용한 작업
  • 운영체제에 파일에 접근할 수 있는 파일 서술자를 요청 후 파일의 정보를 읽을 수 있음

파일을 사용하는 것이 메모리를 사용하는 것보다 복잡한 이유

  1. 디스크에서 특정 주소를 지정(addressing)하는 방법과 메모리에서 특정 주소를 지정하는 방법이 다름
  2. CPU와 외부 장치 간 속도에 차이가 있음

파일에 대해 메모리처럼 직접 디스크의 파일을 읽고 쓸 수는 없을까?

6.5.1 파일과 가상 메모리

  • 사용자 상태에서 메모리는 하나의 연속된 공간이고, 디스크에 저장된 파일도 하나의 연속된 공간
  • 두 공간을 연관 짓는 방법은? → 가상 메모리
  • 가상 메모리의 목적은 모든 프로세스가 각자 독점적으로 메모리를 소유하고 있다고 생각하게 하는 것
  • 프로세스가 만나는 주소 공간은 가상이기 때문에 ‘수작을 부려’작업할 수 있는 공간이기도 함
  • 파일은 개념적으로 연속된 디스크 공간에 저장되어 있다고 생각할 수 있으므로 이 공간을 프로세스 주소 공간에 직접 mapping할 수 있음
    • 길이가 200바이트인 파일을 프로세스 주소 공간에 매핑한 결과로 파일이 600~799 주소 범위에 위치하고 있다고 가정하면, 이 주소 공간에서 바이트 단위로 파일을 처리할 수 있음

6.5.2 마술사 운영 체제

  • 600~799 범위의 주소 공간을 읽을 때, 대응하는 파일이 아직 메모리에 적재되지 않아 페이지 누락(page fault) 인터럽트가 발생할 수 있음
  • 이후 CPU가 운영 체제의 인터럽트 처리함수를 실행하며, 실제 디스크 입출력 요청이 시작
  • 파일을 메모리로 읽고 가상 메모리와 실제 메모리의 연결이 수립되면 프로그램에서 메모리를 읽고 쓰듯이 직접 디스크의 내용을 사용할 수 있음
  • mmap을 사용하더라도 실제로 디스크를 읽고 써야 하기는 하지만 이 과정은 운영체제가 진행함
  • 또한 가상 메모리를 경유하기 때문에 고수준 계층에 있는 사용자는 신경쓸 필요 없음

6.5.3 mmap 대 전통적인 read/write 함수

  • read/write 함수 같은 입출력 함수는 저수준 계층에 시스템 호출을 사용함 → 파일을 읽고 쓸 때 데이터를 커널 상태 ↔ 사용자 상태로 복사해야 해서 큰 부담을 수반
  • mmap은 디스크의 파일을 읽고 쓸 때는 시스템 호출과 데이터 복사가 주는 부담이 없음. 하지만 커널은 프로세스 주소 공간관 파일의 mapping 관계를 유지하기 위해 특정 데이터 구조를 사용해야 하며 이 역시 성능에 부담을 가져옴
  • 이외에 page fault 문제가 있어 인터럽트가 발생하면 이에 상응하는 인터럽트 처리 함수가 실제로 파일을 메모리에 적재해야 함

⇒ mmap 과 read/write 함수의 성능 비교를 할 때 실제 구체적인 상황에서 각각의 방식에 대한 부담을 비교하여 선택해야 함

6.5.4 큰 파일 처리

  • 큰 파일이란 물리 메모리 용량을 초과할 정도의 크기를 가진 파일을 의미
  • 전통적인 read/write 함수를 사용하면 파일을 조금씩 나누어 메모리에 적재해야 하며, 파일의 일부분에 대한 처리가 끝나면 다시 다음 부분에 대한 처리를 하는 방식으로 너무 많은 메모리를 요청하면 OOM 문제가 발생할 수 있음
  • mmap을 사용하면 가상 메모리의 도움하에 프로세스 주소 공간이 충분하다면 큰 파일 전체를 프로세스 주소 공간에 직접 mapping할 수 있으며 해당 파일의 크기가 실제 물리 메모리의 크기보다 크더라도 아무 문제 없음
  • mmap을 호출할 때 매개변수가 MAP_SHARED 라면 사상된 영역의 변경 내용은 디스크의 파일에 직접 기록됨. 시스템은 작업중인 파일의 크기가 물리 메모리의 크기보다 큰지 전혀 관심 없음
  • 매개변수가 MAP_PRIVATE 인 경우, 시스템이 실제로 메모리를 할당한다는 의미로 물리 메모리의 크기에 교환 영역(swap area)의 크기를 더한 크기가 기준이 됨

6.5.5 동적 링크 라이브러리와 공유 메모리

  • 동적 링크 라이브러리의 경우 이를 차조하는 프로그램이 아무리 많더라도 실행 파일에는 라이브러리의 코드와 데이터가 포함되지 않음
  • 라이브러리를 참조하는 모든 프로그램이 메모리에 적자되더라도 동일한 동적 라이브러리를 공유하므로 디스크와 메모리의 공간을 절약할 수 있음

mmap으로 해당 라이브러리를 사용하는 모든 프로세스의 주소 공간에 직접 mapping할 수 있는데 여러 프로세스는 모두 해당 라이브러리가 자신의 주소 공간에 적재되었다고 생각하지만, 실제 물리 메모리에서 이 라이브러리가 차지하는 공간은 한개 크기일 뿐임.

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.4의 내용을 읽고 요약한 글입니다. 

 

6.4.1 파일 서술자

  • 모든 입출력 작업은 파일 읽기와 쓰기로 구현할 수 있음

read 함수를 이용해 파일 내용을 읽을 때 아래와 같이 사용하는데 어디에서 데이터를 읽는가?

read(buffer);
  • 유닉스/리눅스 세계에서 파일을 사용하려면 번호를 필요로하는데 이를 파일 서술자(file descriptor)라고 함. 즉 파일 서술자는 숫자에 불과
  • 프로그래머는 파일 서술자라는 숫자를 통해 입출력을 처리할 수 있음
  • 파일이 디스크에 저장되어있는지, 디스크는 어느 위치에 저장되어 있는지 등의 정보를 운영체제가 대신 처리하므로 프로세스는 이를 신경쓸 필요없음
char buffer[LEN];
int fd = open(file_name); // 파일 서술자 얻기

read(fd, buffer);

6.4.2 다중 입출력을 어떻게 효율적으로 처리하는 것일까?

  • 높은 동시성이란 서버가 동시에 많은 사용자 요청을 처리할 수 있음을 의미
  • 웹 서버에서 3way handshake에 성공하면 accept 함수를 호출하여 연결을 얻을 수 있고, 추가로 파일 서술자도 얻을 수 있음. 파일 서술자로 사용자와 통신을 진행
// accept 함수로 사용자의 파일 서술자 획득
int conn_fd = accept(...);
  • 서버의 처리 작동 방식은 일반적으로 먼저 사용자 요청 데이터를 읽고, 이에 따라 특정한 처리를 실행
if(read(conn_fd, buff) > 0)
{
	do_something(buff);
}
  • read 함수는 일반적으로 블로킹 입출력인데 높은 동시성을 위해서는 파일 서술자 수천수만 개를 처리해야 함
  • 다중 스레드를 생각할 수 있지만 이 방법은 스레드 수가 너무 많아질 수 있고 스레드의 스케쥴링과 전환에 너무 많은 부담이 가해지므로 최적의 방법이 아닐 수 있음

6.4.3 상대방인 아닌 내가 전화하게 만들기

  • 여러개의 파일 서술자에 대해 읽고 쓸 수 있는지 매번 체크하는 것이 아니라 관심 대상인 파일 서술자를 커널에 알려주고, 커널에 대신 감시하다가 읽고 쓸 수 있는 파일 서술자가 있을 때 알려주면 처리하겠다고 이야기 하는 것 ⇒ 입출력 다중화(input/output multiplexing) 기술

6.4.4 입출력 다중화

  • 다중화(multiplexing)라는 용어는 사실 통신 분야에서 많이 사용되는데 통신 선로를 최대한 활용하기 위해 하나의 채널에서 여러 신호를 전송할 수 있어야 함 → 여러 신호를 하나로 합칠 필요가 있음. 여러 신호를 하나로 합치는 장치를 다중화기(multiplexer)라고 함
  • 이 신호를 수신하는 쪽에서는 합쳐진 신호를 다시 원래의 여로 신호로 복원해야 하는데 이 장치를 역다중화기(demultiplexer)라고 함

참고: https://velog.io/@seokjun0915/데이터통신-Multiplexing-1

 

입출력 다중화는 아래 과정을 의미

  1. 파일 서술자를 획득. 서술자 종류는 상관없음
  2. 특정 함수를 호출하여 커널에 다음과 같이 알림. ‘이 함수를 먼저 반환하는 대신, 이 파일 서술자를 감시하다 읽거나 쓸 수 있는 파일 서수자가 나타날 때 반환해주세요’
  3. 해당 함수가 반환되면 읽고 쓸 수 있는 조건이 준비된 파일 서술자를 획득할 수 있으며 이를 통해 사응하는 처리를 할 수 있음

이 기술로 여러 입출력을 한꺼번에 처리할 수 있음. 리눅스에서는 입출력 다중화 기술을 사용하는 방법에 3가지가 있음

 

6.4.5 삼총사: select, poll, epoll

  • 본질적으로 select, poll, epoll은 모두 동기 입출력 다중화 기술
  • 이런 함수가 호출될 때 감시해야 하는 파일 서술자에서 읽기/쓰기 가능 같은 관심 대상 이벤트가 나타나지 않으면 호출된 스레드가 블로킹되어 일시 중지되고, 파일 서술자가 해당 이벤트를 생성할 때까지 함수는 반환되지 않음

select

  • 감시할 수 있는 파일 서술자 묶음에 제한이 있으며 일반적으로 1024개를 넘을 수 없음
  • select가 호출될 때 대응하는 프로세스 또는 스레드는 감시 대상인 파일의 대기열에 배치되므로 select 호출로 브로킹되며 일시 중지 됨
  • 파일 서술자 중 하나라도 읽기 가능 또는 쓰기 가능 이벤트가 나타나면 해당 프로세스 또는 스레드가 다시 깨어남

→ 문제는 프로세스가 깨어났을 때 프로그래머는 어떤 파일 서술자가 읽고 쓸 수 있는지 알 수 없어, 준비완료 상태의 팡리 서술자를 알기 위해 처음부터 끝까지 다시 확인해야 함. 대량의 파일 서술자를 감시할 때 효율이 매우 떨어지는 근본적인 원인

poll

  • select와 매우 유사하지만 최적화된 점은 감시 가능한 파일 서술자 수가 1024개 이상이라는 것
  • 하지만 select와 마찬가지로 파일 서술자 수가 늘어날수록 성능이 저하되는 문제가 있음

epoll

  • 커널에 필요한 데이터 구조를 생성하며 준비 완료된 파일 서술자 목록을 가짐
  • 감시되고 있는 파일 서술자에서 관심 이벤트가 발생하면 해당 프로세스를 깨우면서 준비 완료된 파일 서술자가 준비 완료 목록에 추가 됨
  • 프로세스와 스레드에서 모든 파일 서술자를 처음부터 끝까지 확인할 필요 없이 준비완료된 파일 서술자를 직접 획득할 수 있는데 이는 매우 효율적
반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.3의 내용을 읽고 요약한 글입니다. 

6.3.1 메모리 관점에서 입출력

  • 단순히 메모리의 복사(copy)일 뿐, 그 이상도 이하도 아님
  • 그럼 파일 내용을 읽는 것을 예로 들면, 데이터는 잔치에서 프로세스 주소 공간으로 어떻게 복사될까?

6.3.2 read 함수는 어떻게 파일을 읽을까?

  • 단일 코어 CPU 시스템에 프로세스 A와 프로세스 B 두개가 있고, 프로세스 A가 현재 실행중이라고 가정
1. (속에) put (sth in/into sth); (끼우다) insert; (채우다) pack, stuff
2. (손에) get, obtain, come by
3. (추가하다) add, include

 

  • 프로세스 A에는 파일을 읽는 코드가 있고, 먼저 데이터를 저장하는 버퍼를 정의 후 다음과 같이 read 계열의 함수를 호출
char buffer[LEN];
read(buffer);
  • 저수준 계층에서 시스템 호출을 이용하여 운영체제에 파일 읽기 요청을 보냄
  • 요청은 커널에서 디스크가 이해할 수 있는 명령어로 변환되어 디스크로 전송
  • CPU가 명령어를 실행하는 속도에 비해 디스크 입출력은 매우 느려 운영체제는 귀중한 계산 리소스를 불필요한 대기에 낭비할 수 없음
  • 운영체제는 현재 프로세스(프로세스 A)의 실행을 일시 중지하고 입출력 블로킹 대기열에 넣음

  • 운영체제는 이미 디스크에 입출력 요청을 보낸 상태이며 데이터를 특정 메모리 영역으로 복사하는 작업 시작

 

프로세스 B는 준비 완료 대기열에 들어감

 

  • CPU 코어수에 비해 프로세스의 수가 훨씬 많음. 그래서 이미 다시 실행될 준비가 된 프로세스가 있더라도 바로 CPU가 할당되지 못할 수 있기 때문에 준비 완료 대기열에 들어감

프로세스 A가 블로킹 입출력 요청을 시작해서 일시 중지되더라도 CPU는 쉬지 않고 준비 완료 대기열에서 실행할 수 있는 프로세스를 찾아 실행

  • 프로세스 B는 CPU가 실행하고 있으며 디스크는 프로세스 A의 메모리 공간에 데이터를 쓰고 있음

 

  • 이후 데이터가 프로세스 A의 메모리에 복사하는 작업이 완료되면, 디스크는 CPU에 인터럽트 신호를 보냄
  • CPU는 인터럽트 신호를 받은 후 처리가 중단되었던 함수로 점프, 디스크의 입출력 처리가 완료되었음을 알게 됨
  • 운영체제는 프로세스 A를 입출력 블로킹 대기열에서 꺼내 다시 준비 완료 대기열에 넣음

  • 운영체제는 CPU를 프로세스 A에 할당할지, B에 할당할 지 결정해야 함 → 프로세스 B에 할당된 CPU 시간이 아직 남아 계속 B를 실행하는 것으로 가정
  • 프로세스 A는 계속 대기하고, 일정 시간 후 타이머 인터럽트 신호가 발생하면 운영체제는 프로세스 B를 준비 완료 대기열로 넣음과 동시에 프로세스 A를 준비 완료 대기열에서 꺼내 CPU를 할당

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.2의 내용을 읽고 요약한 글입니다. 

  • 최신 컴퓨터 시스템의 경우, 사실 디스크가 입출력을 처리할 때 CPU개입이 필요하지 않음. 이유는? 아래의 장치 제어기, 직접 메모리 접근, 인터럽트의 관계를 이해해야 함
  • 디스크 입출력 요청을 처리하는 동안 운영 체제는 CPU가 다른 작업을 수행하도록 스케쥴링함

상황

  • CPU가 스레드 1을 실행하기 시작하고 일정 시간 지난 후 파일 읽기와 같은 디스크 관련 입출력을 요청한다고 가정
  • 디스크 입출력은 CPU 속도에 비해 매우 느리기 때문에 입출력 요청의 처리가 완료되기 전까지 스레드 1은 앞으로 나아갈 수 없음
  • 쓰레드 1의 실행은 일시 중지 되고 CPU가 준비 완료 상태인 다른 스레드(스레드 2)에 할당됨
  • 스레드 2가 실행되고, 스레드 1의 디스크 입출력 요청 처리가 완료되면 CPU는 스레드 1을 이어서 실행

6.2.1 장치 제어기

  • 디스크와 같은 입출력 장치는 대체로 기계 부분과 전자 부분으로 나눌 수 있음

기계 부분

출처: https://babytiger.netlify.app/posts/hdd/

  • 플래터(Platter): 실질적으로 데이터가 저장되는 곳, 동그란 원판 모양
  • 스핀들(Spindle): 플래터를 회전시키는 구성요소
  • 헤드(R/W Head): 플래터 위에 미세하게 떠있어 데이터를 읽고 씀
  • Actuator Arm:헤드를 원하는 위치로 이동시킴

입출력 요청이 왔을 때 헤드를 통해 데이터를 읽고 쓰는데 데이터가 헤드가 위치한 트랙에 없을 가능성이 있음

  • 트랙: 플래터를 동심원으로 나누었을 때 그중 하나의 원
  • 이때 헤드가 특정 트랙으로 이동해야 하는데 이 과정을 탐색(seek)이라고 하며 매우 많은 시간이 소모
 

전자 부분

  • 전자 부품으로 구성되어 장치 제어기(device controller)라고 함
  • 자체적인 프로세서와 펌웨어, 버퍼, 레지스터를 갖추고 있어 CPU가 직접 도와주지 않는 상황에서도 복잡한 작업을 할 수 있음
  • 장치드라이버(device driver): 운영체제에 속한 코드인데 반해, 장치 제어기는 장치 드라이버에서 명령을 받아 외부 장치를 제어하는 하드웨어

⇒ 장치 제어기는 운영 체제에 해당하는 장치 드라이버와 외부 장치를 연결하는 다리에 해당

 

6.2.2 CPU가 직접 데이터를 복사해야 할까?

  • 디스크에서 자체 버퍼로 데이터를 읽었다면, 이후 CPU는 직접 데이터 전송 명령어를 실행하여 장치 제어기 버퍼에 있는 데이터를 따로 메모리로 복사해야 할까? 그렇지 않음

  • CPU 개입이 없는 상황에서 직접 장치와 메모리 사이에 데이터를 전송할 수 있는 작동방식을 직접 메모리 접근(direct memory access, DMA)라고 부름

 

6.2.3 직접 메모리 접근

  • CPU가 관여하지 않고, 메모리와 외부 장치 사이에서 데이터를 이동시키는 역할을 하는 것이 직접 메모리 접근
  • CPU가 직접 데이터를 복사하지 않지만, 반드시 어떻게 데이터를 복사할지 알려주는 명령어를 DMA에 전달해야 함
  • DMA는 bus arbitration, 즉 버스 사용권한을 요청한 후 장치를 작동시킴
  • 디스크에서 데이터를 읽을 때 장치 제어기의 버퍼에서 데이터를 읽으면 DMA가 지정된 메모리 주소에 데이터를 쓰는 방식으로 데이터 복사가 완료됨
  • DMA가 데이터 전송을 완료하면 인터럽트 작동 방식을 사용하여 CPU에 알려줌

 

6.2.4 전 과정 정리

스레드 1, 2가 있다고 가정

  • CPU로 실행되는 스레드 1이 시스템 호출로 입출력 요청을 시작하면, 운영 체제는 스레드 1의 실행을 일시 중지하고 CPU를 스레드 2에 할당 후 실행됨
  • 디스크가 동작하여 데이터 준비가 되면, DMA 작동 방식이 직접 장치와 메모리 사이에서 데이터를 전송
  • 데이터 전송이 완료되면 인터럽트 작동 방식을 이용하여 CPU에 알리고 CPU는 스레드 2를 중지 후 스레드 1이 다시 실행

⇒ 핵심은 디스크가 입출력 요청을 처리할 때 CPU가 그 자리에서 기다리지 않고 운영 체제의 스케쥴링에 따라 다른 스레드를 실행한다는 것

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.1의 내용을 읽고 요약한 글입니다. 

 

6.1.5 배달 음식 주문과 중단 처리

  • 배달 음식을 주문 후 즐겁게 게임을 한다고 가정
  • 게임 중 배달 음식이 도착하면 게임을 일시 중지한 후 배달 음식을 받고 자리로 돌아와 게임을 이어할 수 있음 → 인터럽트 처리 과정

 

컴퓨터 시스템에서 기초적인 인터럽트 처리 구조

 
  • CPU가 특정 프로세스(프로세스 A)의 기계 명령어를 실행할 때 새로운 이벤트가 발생
    • 네트워크 카드에 새로운 데이터가 들어오면 외부 장치가 인터럽트 신호를 보내고
    • CPU는 실행 중인 현재 작업의 우선순위가 인터럽트 요청보다 높은지 판단
    • 인터럽트의 우선순위가 더 높다면 현재 작업 실행으 일시 중지하고 인터럽트를 처리하고 다시 현재 작업으로 돌아옴
  • 프로그램은 계속 끊임없이 실행되는 것이 아니라 언제든지 장치에 의해 실행이 중단될 수 있음
  • 하지만 이 과정은 프로그래머에게 드러나지 않으며 중단 없이 실행되고 있는 것처럼 느끼게 만듦

 

6.1.6. 인터럽트 구동식 입출력

인터럽트 발생 시 CPU가 실행하는 명령어 흐름

프로그램 A의 기계 명령어 n 실행
프로그램 A의 기계 명령어 n + 1 실행
프로그램 A의 기계 명령어 n + 2 실행
프로그램 A의 기계 명령어 n + 3 실행
인터럽트 신호 감지
프로그램 A의 실행 상태 저장
인터럽트 처리 기계 명령어 m 실행
인터럽트 처리 기계 명령어 m + 1 실행
인터럽트 처리 기계 명령어 m + 2 실행
인터럽트 처리 기계 명령어 m + 3 실행
프로그램 A의 실행 상태 복원
프로그램 A의 기계 명령어 n + 4 실행
프로그램 A의 기계 명령어 n + 5 실행
프로그램 A의 기계 명령어 n + 6 실행
프로그램 A의 기계 명령어 n + 7 실행
  • 폴링 방식보다 효율적으로 시간을 낭비하지 않음
    • 실제는 약간의 시간을 낭비하는데 주로 프로그램 A의 실행 상태를 저장하고 복원하는데 사용

프로그램 A의 관점에서 CPU가 실행하는 명령어 흐름은 아래와 같음

프로그램 A의 기계 명령어 n 실행
프로그램 A의 기계 명령어 n + 1 실행
프로그램 A의 기계 명령어 n + 2 실행
프로그램 A의 기계 명령어 n + 3 실행
프로그램 A의 기계 명령어 n + 4 실행
프로그램 A의 기계 명령어 n + 5 실행
프로그램 A의 기계 명령어 n + 6 실행
프로그램 A의 기계 명령어 n + 7 실행
  • CPU는 마치 중단된 적이 없는 것처럼 자신의 명령어를 계속 실행 → 프로그램 A의 실행 상태를 저장하고 복원하는 작업이 필요한 이유로 입출력을 비동기로 처리하는 방법을 인터럽트 구동식 입출력이라고 함 (interrupt driven input and output)

6.1.7 CPU는 어떻게 인터럽트 신호를 감지할까?

CPU가 기계 명령어를 실행하는 과정

  • 명령어 인출(instruction fetch)
  • 명령어 해독(instruction decode)
  • 실행(execute)
  • 다시 쓰기(write back)
  • CPU가 하드웨어의 인터럽트 신호를 감지하는 단계

인터럽트 신호가 발생하면 이 이벤트를 처리할지 여부를 반드시 결정해야 함

6.1.8 인터럽트 처리와 함수 호출의 차이

  • 함수를 호출하기 이전에 반환 주소, 일부 범용 레지스터의 값, 매개변수 등 정보 저장이 필요
  • 인터럽트 처리 점프는 서로 다른 두 실행 흐름을 포함하므로 함수 호출에 비해 저장해야 할 정보가 훨씬 많음

6.1.9 중단된 프로그램의 실행 상태 저장과 복원

 

  • 프로그램 A가 실행 중일 때 인터럽트가 발생하면 A의 실행은 중단되고, CPU는 인터럽트 처리 프로그램(interrupt handler) B로 점프
  • CPU가 인터럽트 처리 프로그램 B를 실행할 때 다시 인터럽트가 발생하면 B는 중단되고 CPU는 인터럽트 처리 프로그램 C로 점프
  • CPU가 인터럽트 처리 프로그램 C를 실행할 때 도 다시 인터럽트가 발생하면 C의 실행은 중단되고 CPU는 인터럽트 처리 프로그램 D로 점프
  • D의 실행이 완료되면 프로그램 C, B, A 순서대로 반환됨

상태 저장 순서

  1. 프로그램 A의 상태 저장
  2. 프로그램 B의 상태 저장
  3. 프로그램 C의 상태 저장

상태 복원 순서

  1. 프로그램 C의 상태 복원
  2. 프로그램 B의 상태 복원
  3. 프로그램 A의 상태 복원

상태가 먼저 저장될수록 상태 복원은 더 나중에 됨 → 스택를 사용해 구현하고, 스택에는 다음 기계 명령어 주소와 프로그램의 상태가 저장됨

 

 

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 6.1의 내용을 읽고 요약한 글입니다. 

CPU는 어떻게 입출력 작업을 처리할까?

  • CPU 내부에 레지스터가 있는 것과 마찬가지로, 장치에도 자체적인 레지스터인 장치 레지스터(device register)가 있음
    1. 데이터를 저장하는 레지스터: 사용자가 키보드의 키를 누르면 그 정보는 이런 레지스터에 저장됨
    2. 제어 정보와 상태 정보를 저장하는 레지스터: 읽고 쓰는 작업을 이용하여 장치를 제어하거나 상태를 볼 수 있음

6.1.1 전문적으로 처리하기: 입출력 기계 명령어

Q. 어떤 장치 레지스터를 읽고 써야 하는지 어떻게 알 수 있을까?

A. 장치마다 고유한 주소가 부여되며, 입출력 명령어에 장치 주소를 지정함

6.1.2 메모리 사상 입출력

메모리 사상 입출력(memory mapping input and output): 주소 공간의 일부분을 장치에 할당하여 메모리를 읽고 쓰는 것처럼 장치를 제어하는 방법

  • LOAD와 STORE 명령어 그 자체만으로는 메모리인지, 장치 레지스터를 읽고 쓰는것인지 구분할 수 없으며 추가 적인 정보가 필요 → 메모리 주소 공간(memory address space)

메모리 주소 공간과 실제 메모리 주소는 서로 다른 개념

  • 기계 명령어 관점에서 CPU에 보이는 것은 주소 공간으로 해당 주소의 데이터가 어디에서 오는지 관심을 가질 필요 없음 → 주소 공간의 일부를 장치에 할당할 수 있음

6.1.3 CPU가 키보드를 읽고 쓰는 것의 본질

키보드의 레지스터가 주소 공간의 0xfe00에 매핑되어있다고 가정하면, CPU가 키보드를 읽는 기계 명령어는 아래와 같음

Load R1 0xFE00
  • 주소 공간 oxfe00값을 CPU 레지스터에 적재하는 이 명령어를 이용하여 CPU가 어떻게 키보드에서 데이터를 가져오는지 확인할 수 있음

하지만 문제는 사용자가 언제 키보드를 누를지 확실하지 않음. 어떻게 데이터를 언제 읽어야 할지 알 수 있을까?

  • 최신 CPU의 클럭 주파수는 2~3GHz. 2GHz라고 가정하면, 하나의 클럭 주기는 1/2,000,000,000 = 0.5ns
  • 하나의 기계 명령어가 하나의 클럭 주기마다 실행된다고 가정했을 때, CPU의 속도에 키보드로 입력하는 글자의 속도를 맞춘다면 1초에 20억개를 입력해야 함 → 불가능

⇒ CPU는 특정한 방법을 사용하여 장치의 작업 상태를 얻어야 하는데 이것이 바로 장치 상태 레지스터의 역할

6.1.4 폴링: 계속 검사하기

키보드에서 누른 키의 데이터를 저장하는 레지스터는 0xfe01, 상태 레지스터는 0xfe00위치에 매핑 되어있다고 가정

START
  Load R1 0xFE00        (1)
  BLZ START             (2)
  LOAD R0 0xFE01        (3)
  BL   OTHER_TASK       (4)

(1) Load R1 0xFE00: 현재 키보드 상태를 읽음

(2) BLZ START: 분기 점프 명령어로, 키보드 상태 레지스터 값이 0(아무도 키보드를 누르지 않은 상태)이면, 시작 위치(START)로 점프하여 키보드의 현재 상태 확인, 1이면 아래로 진행

(3) LOAD R0 OxFE01: 키보드의 데이터는 R0 레지스터에 저장

(4) BL OTHER_TASK: 아무론 조건 없이 다른 작업으로 점프

이 코드를 고급언어로 번역하면 다음과 같음

while (키가 눌리지 않음)
{
     ; // 키가 눌릴 때까지 대기 
}

키보드 데이터 읽기
  • 이런 입출력 구현 방법에 polling이라는 매우 직관적인 이름이 붙어있음
  • 문제는 사용자가 키를 누르지 않을 때 CPU는 항상 불필요하게 순환하며 대기하게 됨
  • 일종의 동기식 설계 방식으로 개선할 수 있는 방법은 비동기로 바꾸는 것
 
 
반응형
반응형

이글은 책 "파이썬 클린 코드" ch2의 내용을 읽고 요약 및 추가한 내용입니다. 

 

예시: R-Trie 자료 구조에 대한 노드 모델링

  • 문자열에 대한 빠른 검색을 위한 자료구조라는 정도로만 알고 넘어가기
  • 현재의 문자를 나타내는 value, 다음에 나올 문자를 나타내는 next_ 배열을 가지고 있음
  • linked list나 tree 형태와 비슷

 

from typing import List
from dataclasses import dataclass, field

R = 26

@dataclass
class RTrieNode:
    size = R
    value: int
    next_: List["RTrieNode"] = field(default_factory=lambda: [None] * R)

    def __post_init__(self):
        if len(self.next_) != self.size:
            raise ValueError(f"리스트(next_)의 길이가 유효하지 않음")

  • size는 class variable로 모든 객체가 값을 공유
  • value는 정수형이지만 기본값이 없으므로 객체 생성시 반드시 값을 정해줘야 함
  • next_는 R크기 만큼의 길이를 가진 list로 초기화
  • __post_init__은 next_가 원하는 형태로 잘 생성되었는지 확인하는 검증
from typing import List
from dataclasses import dataclass, field

R = 26  # 영어 알파벳

@dataclass
class RTrieNode:
    size = R
    value: int
    next_: List["RTrieNode"] = field(default_factory=list)

    def __post_init__(self):
        if len(self.next_) != self.size:
            raise ValueError(f"리스트(next_)의 길이가 유효하지 않음")

rt_node = RTrieNode(value=0) # ValueError: 리스트(next_)의 길이가 유효하지 않음

 

이터러블 객체

__iter__ 매직 메소드를 구현한 객체

파이썬의 반복은 이터러블 프로토콜이라는 자체 프로토콜을 사용해 동작

for e in my_object

위 형태로 객체를 반복할 수 있는지 확인하기 위해 파이썬은 고수준에서 아래 두가지 차례로 검사

  • 객체가 __next__나 __iter__ 메서드 중 하나를 포함하는지 여부
  • 객체가 시퀀스이고 __len__과 __getitem__을 모두 가졌는지 여부

For-loop에 대한 구체적인 과정

my_list = ["사과", "딸기", "바나나"]

for i in my_list:
    print(i)
  1. for 문이 시작할 때 my_list의 __iter__()로 iterator를 생성
  2. 내부적으로 i = __next__() 호출
  3. StopIteration 예외가 발생하면 반복문 종료

Iterable과 Iterator의 차이

  • Iterable: loop에서 반복될 수 있는 python 객체, __iter__() 가 구현되어있어야 함
  • Iterator: iterable 객체에서 __iter__() 호출로 생성된 객체로 __iter__()와 __next__()가 있어야하고, iteration 시 현재의 순서를 가지고 있어야 함

 

이터러블 객체 만들기

객체 반복 시 iter() 함수를 호출하고 이 함수는 해당 객체에 __iter__ 메소드가 있는지 확인

from datetime import timedelta
from datetime import date

class DateRangeIterable:
    """자체 이터레이터 메서드를 가지고 있는 iterable"""

    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._present_day = start_date

    def __iter__(self):
        return self # 객체 자신이 iterable 임을 나타냄

    def __next__(self):
        if self._present_day >= self.end_date:
            raise StopIteration()
        today = self._present_day
        self._present_day += timedelta(days=1)

        return today

for day in DateRangeIterable(date(2024, 6, 1), date(2024, 6, 4)):
    print(day)

2024-06-01
2024-06-02
2024-06-03

  • for 루프에서 python은 객체의 iter() 함수를 호출하고 이 함수는 __iter__ 매직 메소드를 호출
  • self를 반환하면서 객체 자신이 iterable임을 나타냄
  • 루프의 각 단계에서마다 자신의 next() 함수를 호출
  • next 함수는 다시 __next__ 메소드에게 위임하여 요소를 어떻게 생산하고 하나씩 반환할 것인지 결정
    • 더 이상 생산할 것이 없는 경우 파이썬에게 StopIteration 예외를 발생시켜 알려줘야함

⇒  for 루프가 작동하는 원리는 StopIteration 예외가 발생할 때까지 next()를 호출하는 것과 같다

 

from datetime import timedelta
from datetime import date

class DateRangeIterable:
    """자체 이터레이터 메서드를 가지고 있는 이터러블"""

    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._present_day = start_date

    def __iter__(self):
        return self

    def __next__(self):
        if self._present_day >= self.end_date:
            raise StopIteration()
        today = self._present_day
        self._present_day += timedelta(days=1)

        return today

r = DateRangeIterable(date(2024, 6, 1), date(2024, 6, 4))
print(next(r))  # 2024-06-01
print(next(r))  # 2024-06-02
print(next(r))  # 2024-06-03
print(next(r))  # raise StopIteration()

위 예제는 잘 동작하지만 하나의 작은 문제가 있음

max 함수 설명

  • iterable한 object를 받아서 그 중 최댓값을 반환하는 내장함수이다
  • 숫자형뿐만 아니라 문자열 또한 비교 가능
str1 = 'asdzCda'
print(max(str1)) # z

str2 = ['abc', 'abd']
print(max(str2)) # abd 유니코드가 큰 값

str3 = ['2022-01-01', '2022-01-02']
print(max(str3)) # 2022-01-02 
# 숫자로 이루어진 문자열을 비교할 때 각 문자열의 앞 부분을 비교해서 숫자가 큰 것을 출력

 

r1 = DateRangeIterable(date(2024, 6, 1), date(2024, 6, 4))

a = ", ".join(map(str, r1))  # "2024-06-01, 2024-06-02, 2024-06-03"
print(max(r1))

ValueError: max() iterable argument is empty

  • 문제가 발생하는 이유는 이터러블 프로토콜이 작동하는 방식 때문
    • 이터러블의 __iter__ 메소드는 이터레이터를 반환하고 이 이터레이터를 사용해 반복
    • 위의 예제에서 __iter__ 는 self를 반환했지만 호출될 때마다 새로운 이터레이터를 만들 수 있음
    • 매번 새로운 DateRangeIterable 인스턴스를 만들어서 해결 가능하지만 __iter__에서 제너레이터(이터레이터 객체)를 사용할 수도 있음

 

from datetime import timedelta
from datetime import date

class DateRangeIterable:
    """자체 이터레이터 메서드를 가지고 있는 이터러블"""

    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._present_day = start_date

    def __iter__(self):
        current_day = self.start_date
        while current_day < self.end_date:
            yield current_day
            current_day += timedelta(days=1)

    def __next__(self):
        if self._present_day >= self.end_date:
            raise StopIteration()
        today = self._present_day
        self._present_day += timedelta(days=1)

        return today

r1 = DateRangeIterable(date(2024, 6, 1), date(2024, 6, 4))

a = ", ".join(map(str, r1))  # 2024-06-01, 2024-06-02, 2024-06-03
print(max(r1))  # 2024-06-03
  • 달라진 점은 각각의 for loop은 __iter__를 호출하고 이는 제너레이터를 생성

⇒ 이러한 형태의 객체를 컨테이너 이터러블(container iterable)이라고 함

 

다른 방법

  • iterable과 iterator 객체를 분리
from datetime import timedelta, date

class DateRangeIterator:
    """Iterator for DateRangeIterable."""

    def __init__(self, start_date, end_date):
        self.current_date = start_date
        self.end_date = end_date

    def __iter__(self):
        return self

    def __next__(self):
        if self.current_date >= self.end_date:
            raise StopIteration()
        today = self.current_date
        self.current_date += timedelta(days=1)
        return today

class DateRangeIterable:
    """Iterable for a range of dates."""

    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date

    def __iter__(self):
        return DateRangeIterator(self.start_date, self.end_date)

r1 = DateRangeIterable(date(2024, 6, 1), date(2024, 6, 4))

# Using join with map
print(", ".join(map(str, r1)))  # Output: 2024-06-01, 2024-06-02, 2024-06-03

# Using max
print(max(r1))  # Output: 2024-06-03

  • DateRangeIterable 에서 __iter__가 호출될 때 마다 새로운 Iterator 를 생성할 수도 있음

 

시퀀스 만들기

객체에 __iter__ 메소드를 정의하지 않았지만 반복하기를 원하는 경우도 있음

객체에 __iter__ 가 정의되어 있지 않으면 __getitem__을 찾고 없으면 TypeError를 발생시킴

시퀀스는 __len__과 __getitem__을 구현하고 첫번째 인덱스0부터 시작하여 포함된 요소를 한 번에 하나씩 가져올 수 있어야 함

이터러블 객체는 메모리를 적게 사용한다는 장점이 있음

  • n번째 요소를 얻고 싶다면 도달할 때까지 n번 반복해야하는 단점이 있음 (시간복잡도: O(n))

⇒CPU 메모리 사이의 trade-off

__iter__, __getitem__ 모두 없는 경우

from datetime import timedelta, date

class DateRangeSequence:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._range = self._create_range()

    def _create_range(self):
        days = []
        current_day = self.start_date
        while current_day < self.end_date:
            days.append(current_day)
            current_day += timedelta(days=1)
        return days

    # def __getitem__(self, day_no):
    #     return self._range[day_no]

    def __len__(self):
        return len(self._range)

s1 = DateRangeSequence(date(2022, 1, 1), date(2022, 1, 5))
for day in s1:
    print(day)

TypeError: 'DateRangeSequence' object is not iterable

 

__getitem__있는 경우

from datetime import timedelta, date

class DateRangeSequence:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._range = self._create_range()

    def _create_range(self):
        days = []
        current_day = self.start_date
        while current_day < self.end_date:
            days.append(current_day)
            current_day += timedelta(days=1)
        return days

    def __getitem__(self, day_no):
        return self._range[day_no]

    def __len__(self):
        return len(self._range)

s1 = DateRangeSequence(date(2022, 1, 1), date(2022, 1, 5))
for day in s1:
    print(day)

2022-01-01
2022-01-02
2022-01-03
2022-01-04

  • __iter__ 없어도 for loop에 사용할 수 있음

 

컨테이너 객체

__contains__ 메서드를 구현한 객체. 일반적으로 boolean 값을 반환하고 이 메서드는 파이썬에서 in 키워드가 발견될 때 호출됨

element in container

위 코드를 파이썬은 아래와 같이 해석 (잘활용하면 가독성이 정말 높아짐)

container.__contains_(element)

 

def mark_coordinate(grid, coord):
    if 0<= coord.x < grid.width and 0<= coord.y < grid.height:
        grid[coord] = MARKED
  • grid내에 coord 좌표가 포함되는지 여부를 확인하는 코드

Grid 객체 스스로 특정 좌표가 자신의 영역안에 포함되는지 여부를 판단할 수는 없을까? 더 작은 객체 (Boundaries)에 위임하면 어떨까?

  • 컴포지션을 사용하여 포함관계를 표현하고 다른 클래스에 책임을 분배하고 컨테이너 매직 메소드를 사용
class Boundaries:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def __contains__(self, coord):
        x, y = coord
        return 0 <= x < self.width and 0 <= y < self.height

class Grid:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.limits = Boundaries(width, height)

    def __contains__(self, coord):
        return coord in self.limits

 

Composition 관계 사용 전

def mark_coordinate(grid, coord):
    if 0<= coord.x < grid.width and 0<= coord.y < grid.height:
        grid[coord] = MARKED

Composition 관계 사용 후

def mark_coordinate(grid, coord):
    if coord in grid:
        grid[coord] = MARKED

 

객체의 동적인 생성

__getattr__ 매직 메소드를 사용하면 객체가 속성에 접근하는 방법을 제어할 수 있음

myobject.myattribute 형태로 객체의 속성에 접근하려면 instance의 속성 정보를 가지고 __dict__에 myattribute가 있는지 검색.

  • 해당 이름의 속성이 있으면 __getattribute__메소드를 호출
  • 없는 경우 조회하려는 속성(myattribute) 이름을 파라미터로 __getattr__ 호출
class DynamicAttributes:
    def __init__(self, attribute):
        self.attribute = attribute

    def __getattr__(self, attr):
        if attr.startswith("fallback_"):
            name = attr.replace("fallback_", "")
            return f"[fallback resolved] {name}"
        raise AttributeError(f"{self.__class__.__name__}에는 {attr} 속성이 없음")

dyn = DynamicAttributes("value")
print(dyn.attribute)  # value

print(dyn.fallback_test)  # [fallback resolved] test

dyn.__dict__["fallback_new"] = "new value" # dict로 직접 인스턴스에 추가
print(dyn.fallback_new)  # new value 

print(getattr(dyn, "something", "default"))  # default

호출형 객체(callable)

  • 함수처럼 동작하는 객체를 만들면 데코레이터 등 편리하게 사용 가능
    • __call__ 매직 메소드가 호출됨
from collections import defaultdict

class CallCount:
    def __init__(self):
        self._counts = defaultdict(int)

    def __call__(self, argument):
        self._counts[argument] += 1
        return self._counts[argument]

cc = CallCount()
print(cc(1))  # 1
print(cc(2))  # 1
print(cc(1))  # 2
print(cc(1))  # 3
print(cc("something"))  # 1
print(callable(cc))  # True

매직 메소드 요약

사용 예 매직 메서드 비고

사용예 매직 메소드 비고
obj[key]
obj[i:j]
obj[i:j:k]
__getitem__(key) 첨자형(subscriptable) 객체
with obj: ... __enter__ / __exit__ 컨텍스트 관리자
for i in obj: ... __iter__ / __next__
__len__ / __getitem__
이터러블 객체
시퀀스
obj.<attribute> __getattr__ 동적 속성 조회
obj(*args, **kwargs) __call__(*arg, **kwargs) 호출형(callable) 객체
  • 이러한 매직 메소드를 올바르게 구현하고 같이 구현해야 하는 조합이 뭔지 확인하는 가장 좋은 방법은 collections.abc 모듈에서 정의된 추상클래스를 상속하는 것

파이썬에서 유의할 점

mutable 파라미터의 기본 값

def wrong_user_display(user_metadata: dict = {"name": "John", "age": 30}):
    name = user_metadata.pop("name")
    age = user_metadata.pop("age")

    return f"{name} ({age})"

2가지 문제 존재

  1. 변경 가능한 기본 값을 사용한 것. 함수의 본문에서 수정 가능한 객체의 값을 직접 수정하여 부작용 발생
  2. 기본 인자
    1. 함수에 인자를 사용하지 않고 호출할 경우 처음에만 정상 동작
    2. 파이썬 인터프리터는 함수의 정의에서 dictionary를 발견하면 딱 한번만 생성하기 때문에 pop하는 순간 해당 key, value는 없어짐
print(wrong_user_display())  # John (30)
print(wrong_user_display())  # KeyError: 'name'

참고 링크

수정방법은?

  • 기본 초기 값을 None으로 하고 함수 본문에서 기본 값을 할당
def wrong_user_display(user_metadata: dict = None):
    user_metadata = user_metadata or {"name": "John", "age": 30}
    name = user_metadata.pop("name")
    age = user_metadata.pop("age")

    return f"{name} ({age})"

 

내장(built-in) 타입 확장

  • 내장 타입을 확장하는 올바른 방법은 list, dict 등을 직접 상속받는 것이 아니라 collections 모듈을 상속받는 것
    • collections.UserDict
    • collections.UserList
  • 파이썬을 C로 구현한 CPython 코드가 내부에서 스스로 연관된 부분을 모두 찾아서 업데이트 해주지 않기 때문
class BadList(list):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "짝수"
        else:
            prefix = "홀수"
        return f"[{prefix}] {value}"

b1 = BadList((0, 1, 2, 3, 4, 5))
print(b1)
print(b1[0])  # [짝수] 0
print(b1[1])  # [홀수] 1
print("".join(b1)) # TypeError: sequence item 0: expected str instance, int found
from collections import UserList

class BadList(UserList):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "짝수"
        else:
            prefix = "홀수"
        return f"[{prefix}] {value}"

b1 = BadList((0, 1, 2, 3, 4, 5))
print(b1)
print(b1[0])  # [짝수] 0
print(b1[1])  # [홀수] 1
print("".join(b1))  #  [짝수] 0[홀수] 1[짝수] 2[홀수] 3[짝수] 4[홀수] 5

 

반응형
반응형

이글은 책 "파이썬 클린 코드" ch2의 내용을 읽고 요약 및 추가한 내용입니다. 

 

 

pythonic 코드란?

  • 일종의 python 언어에서 사용되는 관용구

 

Pythonic 코드를 작성하는 이유

  • 일반적으로 더 나은 성능을 보임
  • 코드도 더 작고 이해하기 쉬움

 

인덱스와 슬라이스

  • 파이썬은 음수 인덱스를 사용하여 끝에서부터 접근이 가능
my_numbers = (4, 5, 3, 9)
print(my_numbers[-1]) # 9
print(my_numbers[-3]) # 5
  • slice를 이용하여 특정 구간의 요소를 얻을 수 있음
    • 끝 인덱스는 제외
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
print(my_numbers[2:5])  # (2, 3, 5)
print(my_numbers[::]) # (1, 1, 2, 3, 5, 8, 13, 21)

간격 값 조절

  • index를 2칸씩 점프
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
print(my_numbers[1:7:2])  # 1, 3, 8
  • slice 함수를 직접 호출할 수도 있음
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)

interval = slice(1, 7, 2)
print(my_numbers[interval]) # (1, 3, 8)

 

자체 시퀀스 생성

  • indexing 및 slice는 __getitem__ 이라는 매직 메서드 덕분에 동작
  • 클래스가 시퀀스임을 선언하기 위해 collections.abc모듈의 Sequence 인터페이스를 구현해야 함
class C(Sequence):                      # Direct inheritance
    def __init__(self): ...             # Extra method not required by the ABC
    def __getitem__(self, index):  ...  # Required abstract method
    def __len__(self):  ...             # Required abstract method
    def count(self, value): ...         # Optionally override a mixin method
from collections.abc import Sequence

class Items:
    def __init__(self, *values):
        self._values = list(values)

    def __len__(self):
        return len(self._values)

    def __getitem__(self, item):
        return self._values.__getitem__(item)

items = Items(1, 2, 3)
print(items[2])  # 3
print(items[0:2]) # [1, 2]
  • 다음 사항에 유의해 시퀀스를 구현해야 함
    • 범위로 인덱싱하는 결과는 해당 클래스와 같은 타입의 인스턴스여야 한다. -> 지키지 않는 경우 오류 발생 가능성
    • 슬라이스에 의해 제공된 범위는 마지막 요소를 제외해야 한다. -> 파이썬 언어와 일관성 유지

컨텍스트 관리자(context manager)

  • 사전 조건과 사후 조건이 있는 일부 코드를 실행해야 하는 상황에 유용
    • 리소스 관리와 관련된 컨텍스트 관리자 자주 볼 수 있음
def process_file(fd):
    line = fd.readline()
    print(line)

fd = open("test.txt")
try:
    process_file(fd)
finally:
		print("file closed")
    fd.close()

123 file closed

똑같은 기능을 매우 우아하게 파이썬 스럽게 구현

def process_file(fd):
    line = fd.readline()
    print(line)

with open("test.txt") as fd:
    process_file(fd)

 

context manager는 2개의 매직 메소드로 구성

  • __enter__ : with 문이 호출
  • __exit__ : with 블록의 마지막 문장이 끄나면 컨텍스트가 종료되고 __exit__가 호출됨

context manager 블록 내에 예외 또는 오류가 있어도 __exit__ 메소드는 여전히 호출되므로 정리 조건을 안정하게 실행하는데 편함

예시: 데이터베이스 백업

  • 백업은 오프라인 상태에서 해야함 (데이터베이스가 실행되고 있지 않는 동안) → 서비스 중지 필요

방법 1

  • 서비스를 중지 → 백업 → 예외 및 특이사항 처리 → 서비스 다시 처리 과정을 단일 함수로 만드는 것
def stop_database():
    run("systemctl stop postgresql.service")

def start_database():
    run("systemctl start postgresql.service")

class DBHandler:
    def __enter__(self):
        stop_database()
        return self

    def __exit__(self, exc_type, ex_value, ex_traceback):
        start_database()

    def db_backup():
        run("pg_dump database")

    def main():
        with DBHandler():
            db_backup()
  • DBHandler 를 사용한 블록 내부에서 context manager 결과를 사용하지 않음
    • __enter__에서 무언가를 반환하는 것이 좋은 습관
  • main() 에서 유지보수 작업과 상관없이 백업을 실행. 백업에 오류가 있어도 여전히 __exit__을 호출
  • __exit__의 반환 값을 잘 생각해야 함. True를 반환하면 잠재적으로 발생한 예외를 호출자에게 전파하지 않고 멈춘다는 뜻으로 예외를 삼키는 것은 좋지 않은 습관

Context manager 구현

  1. contextlib.contextmanager 데코레이터 사용
import contextlib

@contextlib.contextmanager
def db_handler():
    try:
        stop_database()  (1)
        yield            (2)
    finally:
        start_database() (4)

with db_handler():
    db_backup()          (3)

@contextlib.contextmanager

  • 해당 함수의 코드를 context manager로 변환
  • 함수는 generator라는 특수한 함수의 형태여야 하는데 이 함수는 코드의 문장을 __enter__와 __exit__매직 메소드로 분리한다.
    • yield 키워드 이전이 __enter__ 메소드의 일부처럼 취급
    • yield 키워드 다음에 오는 모든 것들을 __exit__로직으로 볼 수 있음

 

2. contextlib.ContextDecorator 클래스 사용

import contextlib

def stop_database():
    print("stop database")

def start_database():
    print("start database")

def run(text):
    print(text)

class dbhandler_decorator(contextlib.ContextDecorator):
    def __enter__(self):
        stop_database()
        return self

    def __exit__(self, ext_type, ex_value, ex_traceback):
        start_database()

@dbhandler_decorator()
def offline_backup():
    run("pg_dump database")

offline_backup()

stop database
pg_dump database
start database

  • with 문이 없고 함수를 호출하면 offline_backup 함수가 context manager 안에서 자동으로 실행됨
  • 원본 함수를 래핑하는 데코레이터 형태로 사용
    • 단점은 완전히 독립적이라 데코레이터는 함수에 대해 아무것도 모름 (사실 좋은 특성)

contextlib 의 추가적인 기능

import contextlib

with contextlib.suppress(DataConversionException):
    parse_data(nput_json_or_dict)
  • 안전하다고 확신되는 경우 해당 예외를 무시하는 기능
  • DataConversionException이라고 표현된 예외가 발생하는 경우 parse_data 함수를 실행

컴프리헨션과 할당 표현식

  • 코드를 간결하게 작성할 수 있고 가독성이 높아짐
def run_calculation(i):
    return i

numbers = []

for i in range(10):
    numbers.append(run_calculation(i))

print(numbers) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

위의 코드를 아래와 같이 바로 리스트 컴프리헨션으로 만들 수 있음

numbers = [run_calculation(i) for i in range(10)]
  • list.append를 반복적으로 호출하는 대신 단일 파이썬 명령어를 호출하므로 일반적으로 더 나은 성능을 보임

dis 패키지를 이용한 어셈블리코드 비교각 assembly 코드 (list comprehension)

import dis

def run_calculation(i):
    return i

def list_comprehension():
    numbers = [run_calculation(i) for i in range(10)]
    return numbers

# Disassemble the list comprehension function
dis.dis(list_comprehension)

def for_loop():
    numbers = []
    for i in range(10):
        numbers.append(run_calculation(i))
    return numbers

# Disassemble the for loop function
dis.dis(for_loop)

 

각 assembly 코드 (list comprehension)

  6           0 LOAD_CONST               1 (<code object <listcomp> at 0x7f8e5a78f710, file "example.py", line 6>)
              2 LOAD_CONST               2 ('list_comprehension.<locals>.<listcomp>')
              4 **MAKE_FUNCTION**            0
              6 LOAD_GLOBAL              0 (range)
              8 LOAD_CONST               3 (10)
             10 **CALL_FUNCTION**            1
             12 GET_ITER
             14 CALL_FUNCTION            1
             16 RETURN_VALUE

 # for loop 
 10           0 BUILD_LIST               0
              2 STORE_FAST               0 (numbers)
 11           4 SETUP_LOOP              28 (to 34)
              6 LOAD_GLOBAL              0 (range)
              8 LOAD_CONST               1 (10)
             10 CALL_FUNCTION            1
             12 GET_ITER
        >>   14 FOR_ITER                16 (to 32)
             16 STORE_FAST               1 (i)
 12          18 LOAD_FAST                0 (numbers)
             20 LOAD_ATTR                1 (append)
             22 LOAD_GLOBAL              2 (run_calculation)
             24 LOAD_FAST                1 (i)
             26 CALL_FUNCTION            1
             28 CALL_METHOD              1
             30 POP_TOP
             32 JUMP_ABSOLUTE           14
        >>   34 POP_BLOCK
 13     >>   36 LOAD_FAST                0 (numbers)
             38 RETURN_VALUE

 

리스트 컴프리헨션 예시

import re
from typing import Iterable, Set

# Define the regex pattern for matching the ARN format
ARN_REGEX = r"arn:(?P<partition>[^:]+):(?P<service>[^:]+):(?P<region>[^:]*):(?P<account_id>[^:]+):(?P<resource_id>[^:]+)"

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """
    collected_account_ids = set()
    for arn in arns:
        matched = re.match(ARN_REGEX, arn)
        if matched is not None:
            account_id = matched.groupdict()["account_id"]
            collected_account_ids.add(account_id)
    return collected_account_ids

# Example usage
arns = [
    "arn:aws:iam::123456789012:user/David",
    "arn:aws:iam::987654321098:role/Admin",
    "arn:aws:iam::123456789012:group/Developers",
]

unique_account_ids = collect_account_ids_from_arns(arns)
print(unique_account_ids)
# {'123456789012', '987654321098'}

위 코드 중 collect_account_ids_from_arns 함수를 집중해서 보면,

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """
    collected_account_ids = set()
    for arn in arns:
        matched = re.match(ARN_REGEX, arn)
        if matched is not None:
            account_id = matched.groupdict()["account_id"]
            collected_account_ids.add(account_id)
    return collected_account_ids

위 코드를 컴프리헨션을 이용해 간단히 작성 가능

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """

    matched_arns = filter(None, (re.match(ARN_REGEX, arn) for arn in arns))
    return {m.groupdict()["account_id"] for m in matched_arns}

python 3.8이후에는 할당표현식을 이용해 한문장으로 다시 작성 가능

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """
    arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
    """

    return {
        matched.groupdict()["account_id"]
        for arn in arns
        if (matched := re.match(ARN_REGEX, arn)) is not None
    }
  • 정규식 이용한 match 결과들 중 None이 아닌 것들만 matched 변수에 저장되고 이를 다시 사용

더 간결한 코드가 항상 더 나은 코드를 의미하는 것은 아니지만 분명 두번째나 세번째 코드가 첫번째 코드보다는 낫다는 점에서는 의심의 여지가 없음

 

프로퍼티, 속성(attribute)과 객체 메서드의 다른 타입들

파이썬에서의 밑줄

class Connector:
    def __init__(self, source):
        self.source = source
        self._timeout = 60

conn = Connector("postgresql://localhost")
print(conn.source)  # postgresql://localhost
print(conn._timeout)  # 60

print(conn.__dict__)  # {'source': 'postgresql://localhost', '_timeout': 60}
  • source와 timeout이라는 2개의 속성을 가짐
    • source는 public, timeout은 private
    • 하지만 실제로는 두 개의 속성에 모두 접근 가능
  • _timeout는 connector 자체에서만 사용되고 바깥에서는 호출하지 않을 것이므로 외부 인터페이스를 고려하지 않고 리팩토링 가능

2개의 밑줄은? (__timeout) → name mangling 으로 실제로 다른 이름을 만듦

  • _<classname>__<attribute-name>
class Connector:
    def __init__(self, source):
        self.source = source
        self.__timeout = 60

conn = Connector("postgresql://localhost")
print(conn.source)  # postgresql://localhost

print(conn.__dict__)  
# {'source': 'postgresql://localhost', '_Connector__timeout': 60}
  • __timeout → 실제 이름은_Connector__timeout 이 됨
  • 이는 여러번 확장되는 클래스의 메소드 이름을 충돌없이 오버라이드 하기 위해 만들어진거로 pythonic code의 예가 아님

결론

⇒ 속성을 private으로 정의하는 경우 하나의 밑줄 사용

 

프로퍼티(Property)

class Coordinate:
    def __init__(self, lat: float, long: float) -> None:
        self._latitude = self._longitude = None
        self.latitude = lat
        self.longitude = long

    @property
    def latitude(self) -> float:
        return self._latitude
    
    @latitude.setter
    def latitude(self, lat_value: float) -> None:
        print("here")
        if lat_value not in range(-90, 90+1):
            raise ValueError(f"유호하지 않은 위도 값: {lat_value}")
        self._latitude = lat_value

    @property
    def longitude(self) -> float:
        return self._longitude
    
    @longitude.setter
    def longitude(self, long_value: float) -> None:
        if long_value not in range(-180, 180+1):
            raise ValueError(f"유효하지 않은 경도 값: {long_value}")
        self._longitude = long_value

coord = Coordinate(10, 10)
print(coord.latitude)

coord.latitude = 190 # ValueError: 유호하지 않은 위도 값: 190
  • property 데코레이터는 무언가에 응답하기 위한 쿼리
  • setter는 무언가를 하기 위한 커맨드

둘을 분리하는 것이 명령-쿼리 분리 원칙을 따르는 좋은 방법

보다 간결한 구문으로 클래스 만들기

객체의 값을 초기화하는 일반적인 보일러플레이트

  • 보일러 플레이트: 모든 프로젝트에서 반복해서 사용하는 코드
def __init__(self, x, y, ...):
    self.x = x
    self.y = y
  • 파이썬 3.7부터는 dataclasses 모듈을 사용하여 위 코드를 훨씬 단순화할 수 있다 (PEP-557)
    • @dataclass 데코레이터를 제공
  • 클래스에 적용하면 모든 클래스의 속성에 대해서 마치 __init__ 메소드에서 정의한 것처럼 인스턴스 속성으로 처리
  • @dataclass 데코레이터가 __init__ 메소드를 자동 생성
  • field라는 객체 제공해서 해당 속성에 특별한 특징이 있음을 표시
    • 속성 중 하나가 list처럼 변경가능한 mutable 데이터 타입인 경우 __init__에서 비어 있는 리스트를 할당할 수 없고 대신에 None으로 초기화한 다음에 인스턴스마다 적절한 값으로 다시 초기화 해야함

 

from dataclasses import dataclass

@dataclass
class Foo:
    bar: list = []

# ValueError: mutable default <class 'list'> for field a is not allowed: use default_factory
  • 안되는 이유는 위의 bar 변수가 class variable이라 모든 Foo 객체들 사이에서 공유되기 때문
class C:
  x = [] # class variable

  def add(self, element):
    self.x.append(element)

c1 = C()
c2 = C()
c1.add(1)
c2.add(2)
print(c1.x)  # [1, 2]
print(c2.x)  # [1, 2]

 

아래처럼 default_factory 파라미터에 list 를 전달하여 초기값을 지정할 수 있도록 하면 됨

from dataclasses import dataclass, field

@dataclass
class Foo:
    bar = field(default_factory=list)

__init__ 메소드가 없는데 초기화 직후 유효성 검사를 하고 싶다면?

⇒ __post_init__에서 처리 가능

반응형
반응형

이 글은  컴퓨터 밑바닥의 비밀 chapter 5.3의 내용을 읽고 요약한 글입니다. 

5.3.1 캐시와 메모리 상호 작용의 기본 단위: 캐시 라인

캐시 라인(Cache line)이란?

  • 프로그램이 어떤 데이터에 접근하면 다음에도 인접한 데이터에 접근할 가능성이 높으므로 데이터가 있는 곳의 ‘묶음’ 데이터를 캐시에 저장하는데 이 ‘묶음’을 캐시 라인이라고 함

캐시와 메모리가 상호 작용하는 기본 단위는 캐시 라인이며, 이 크기는 일반적으로 64 바이트임.

캐시가 적중하지 모샇는 경우 이 묶음 데이터가 캐시에 저장됨.

5.3.2 첫번째 성능 방해자: 캐시 튕김 문제

아래 2가지 코드가 있음

첫번째 코드

atomic<int> a;

void threadf()
{
	for(int i=0;i<500000000;i++)
	{
		++a;
	}
}

void run()
{
	thread t1 = thread(threadf);
	thread t2 = thread(threadf);
	
	t1.join();
	t2.join();
}
  • 2개의 스레드를 시작하는데 각 스레드를 전역변수 a값을 1씩 5억번씩 증가

두번째 코드

atomic<int> a;

void run()
{
	for(int i=0;i<1000000000;i++)
	{
		++a;
	}
}
  • 단일 스레드로 전역변수 a값을 1씩 10억번씩 증가
 

 

어떤 코드의 속도가 더 빠를까?

  • 다중 코어 컴퓨터 기준 첫번째 프로그램의 실행시간이 16초, 두번째 실행시간은 8초에 불과했음
  • 병렬 계산임에도 다중 스레드가 단일 스레드보다 느린 이유는?
  • 리눅스의 perf 도구를 사용하여 두 코드를 분석할 수 있음
  • “perf stat” 명령어는 프로그램 실행 시에 나타나는 각종 주요 정보의 통계를 보여주는데 여러 항목 중 insn per cycle 항목에서 차이를 보임

insn per cycle

  • 하나의 클럭 주기에 CPU가 실행하는 프로그램에서 기계 명령어를 몇개 실행하는지 알려줌
  • 다중 스레드는 0.15, 단일 스레드는 0.6으로 단일 스레드 프로그램에서 하나의 클럭 주기 동안 기계 명령어나 4배나 다 많이 실행되었음. 이유는?

캐시 일관성을 보장하기 위해 두 코어의 캐시에서 전역 변수 a처럼 동일한 변수가 사용될 때는 두 캐시에 모두 저장됨

 

두 스레드는 모두 해당 변수에 1을 더해야 함. 이때 첫번 째 스레드가 아래 그림과 같이 a 변수에 덧셈 연산을 실행하기 시작한다면, 다른 cpu 캐시의 a 변수를 무효화(invalidation) 해야 함 → 캐시 튕김 발생

  1. 캐시와 메모리의 불일치 문제를 방지 하기 위해 메모리의 a 변수 값도 업데이트
  2. 동시에 다른 cpu 캐시에 있는 a 변수 값을 무효화

 

1. 아래 cpu의 캐시가 무효화되어 어쩔 수 없이 메모리에서 직접 a변수 값을 읽어야 함

 

  1. 아래 cpu도 a 변수에 1을 더하고 캐시이 일관성을 보장하기 위해 메모리에 a 변수 값을 업데이트
  2. 위 cpu 캐시의 a 변수 무효화 → 또 다시 캐시 튕김이 발생

 

이와 같이 각 cpu의 캐시가 끊임없이 서로 상대 캐시를 무효화하면서 튕겨냄
⇒ 여러 스레드 사이에 데이터 공유를 피할 수 있다면 가능한 피해야 함을 의미

 

5.3.3 두번째 성능 방해자: 거짓 공유 문제

첫번째 코드

struct data
{
	int a;
	int b;
};

struct data global_data;

void add_a()
{
	for(int i=0;i<50000000;i++)
	{
		++ global_data.a;
	}
}

void add_b()
{
	for(int i=0;i<50000000;i++)
	{
		++ global_data.b;
	}
}

void run()
{
	thread t1 = thread(add_a);
	thread t2 = thread(add_b);
	
	t1.join();
	t2.join();
}
  • 스레드 2개를 시작한 후 구조체의 a 변수와 b 변수를 1씩 5억번 증가시킴

두번째 프로그램

void run()
{
	for(int i=0;i<50000000;i++)
	{
		++global_data.a;
	}
	
	for(int i=0;i<50000000;i++)
	{
		++global_data.b;
	}
}
  • 단일 스레드로 동일하게 a 변수와 b 변수를 1씩 5억 번 증가시킴
  • 첫번째 코드가 두 변수를 공유하지 않고 다중 쓰레드 프로그램이니 더 빠르게 실행될 것이라고 예상할 수 있음 → 사실을 그렇지 않음
  • 사실 두 스레드는 어떤 변수도 공유하지 않지만 이 두 변수는 동일한 캐시 라인(cache line)에 있을 가능성이 매우 높아 캐시 튕김 문제가 발생할 수 있음 ⇒ 거짓 공유(false sharing)이라고 함

개선하는 방법으로 두 변수가 같은 캐시라인에 있지 않도록 하는 것인데 아래처럼 구조체를 구성하면 가능

struct data
{
	int a;
	int arr[16];
	int b;
}
  • 다중 코어 컴퓨터에서 캐시 라인 크기가 64바이트이며, arr[16]을 통해 int 형식의 배열을 채우면 a 변수와 b변수는 다른 캐시라인에 있게 됨.
반응형
반응형

Python 공식문서에 따르면 super 클래스의 역할은 아래와 같음

Return a proxy object that delegates method calls to a parent or sibling class of type. This is useful for accessing inherited methods that have been overridden in a class.

공식문서 설명은 늘 어려움.

쉽게 말해, 부모나 형제 클래스의 임시 객체를 반환하고, 반환된 객체를 이용해 슈퍼 클래스의 메소드를 사용할 수 있음.

즉, super() 를 통해 super class의 메소드에 접근 가능

단일상속에서 super()

 
class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * self.length + 2 * self.width

 

class Square(Rectangle):
    def __init__(self, length):
        super().__init__(length, length)

square = Square(4)
square.area() # 16
  • Rectangle 클래스를 상속받기 때문에 Rectangle의 area() 메소드 사용 가능

 

super() with parameters


  • super() 는 2가지 파라미터를 가질 수 있음
    • 첫번째 : subclass
    • 두번째 : subclass의 인스턴스 객체
class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * self.length + 2 * self.width

class Square(Rectangle):
    def __init__(self, length):
        super(Square, self).__init__(length, length)
  • 단일 상속인 경우에는 super(Square, self)와 super()는 같은 의미

아래의 경우는?

class Cube(Square):
    def surface_area(self):
        face_area = super(Square, self).area()
        return face_area * 6

super(Square, self).area()

첫번째 argument : subclass 인 Square

  • Cube가 아닌 Square기 때문에 super(Square, self)의 반환은 Square 클래스의 부모 클래스인 Rectangle 클래스의 임시 객체
  • 결과적으로 Rectangle 인스턴스에서 area() 메소드를 찾음

Q. Square 클래스에 area 메소드를 구현하면??

  • 그래도 super(Square, self) 가 Rectangle 클래스를 반환하기 때문에 Rectangle 인스턴스에서 area() 메소드를 호출
## super 클래스의 정의
class super(object):
	def __init__(self, type1=None, type2=None): # known special case of super.__init__
	        """
	        super() -> same as super(__class__, <first argument>)
	        super(type) -> unbound super object
	        **super(type, obj) -> bound super object; requires isinstance(obj, type)
	        super(type, type2) -> bound super object; requires issubclass(type2, type)**
	        Typical use to call a cooperative superclass method:
	        class C(B):
	            def meth(self, arg):
	                super().meth(arg)
	        This works for class methods too:
	        class C(B):
	            @classmethod
	            def cmeth(cls, arg):
	                super().cmeth(arg)
					"""
	        
	        # (copied from class doc)

두번째 argument : 첫번째 argument의 클래스 인스턴스를 넣어주거나 subclass를 넣어줘야함

print(issubclass(Cube, Square)) # True
반응형

+ Recent posts