-
Notifications
You must be signed in to change notification settings - Fork 0
윤중진
- 체스를 예전에 몇 번 해보기도 했고, 보기도 해서 도메인 지식이 충분할 줄 알았다.
- 그런데.. 앙파상, 캐슬링, 체크, 체크메이트 등등 특수 현상에 대한 지식이 부족했고 이를 구현하지 못했다.
- 평소에 나의 개발에 대한 신념은 항상 같았다.
- 깨끗한 쓰레기보다 쓸모있는 고철 덩어리가 낫다는 것이다.
- 즉, oop, clean code, XXX driven devolop 이런 개념을 아무리 고민하고 적용해도, 완성도가 낮은 애플리케이션이면 사용 못하는 것 아닌가?
- 애플리케이션은 반드시, 특히 백엔드 개발자라면 더더욱 SPOF에 잘 대처할 수 있어야 하고 그에따라 높은 availability를 유지해야 한다.
- 그런데 이번 주차에 많은 캠퍼분들은 어떻게 하면 깨끗하고, 유지보수하기 쉬운 코드를 짤 수 있을까에 대해 더 많이 고민을 하는 것 같았다.
- 이렇게 나와 의견이 다른 사람이 너무 많다는 것에 대해 혼란스러웠는데 마지막 코치님께서 내 질문에 대해 답변해주신 것을 듣고 확고해졌다.
- 결국 우리는 대용량 트래픽 사이에서 고가용성(high tps) 애플리케이션을 개발 해야만 하고, 아무리 유지 보수성 높은 코드를 작성해도 모든 시대의 변화에 대처할 수 없다는 것이다.
- 다음 주에는 WAS를 구현하는 프로젝트를 진행한다고 하는데, 정말 재밌겠다.
- 주말에 Network 복습..
- WAS를 구현한다고 하길래 지레 겁먹고 WAS에 관한 모든 코드와 개념을 먼저 숙지하도록 노력했다.
- 그래서인가 구현이 조금 늦어진 것 같다.
- thread를 생성하는 부분에서, java의 Thread 객체는 가상 머신 안에서 동작한다. 하지만 가상 머신이라고 해봤자, 실제로 가상 머신이 내 컴퓨터의 자원을 직접 사용하는 것이 아니라, 커널과의 상호 작용을 통해, 즉 커널의 스레드와 매핑해서 사용된다는 것을 배웠다.
- 그래서 user thread <-> kernel thread 간의 매핑 과정에 대해 알아보았고, linux Light Weight Process에 대해서도 알아보았다.
- 스레드인 것 같은데 왜 Process라 불리는 거지? 했는데 호눅스 님께서 이 LWP 덕분에 리눅스는 사실상 프로세스와 스레드의 경계가 희미하다고 말씀해 주셔서 이해가 됐다.
- 그래서 user thread <-> kernel thread 간의 매핑 과정에 대해 알아보았고, linux Light Weight Process에 대해서도 알아보았다.
- SMT도 공부했는데, 다른 스레드이면서 서로 의존성이 없는 명령어들을 여러 operation unit에 함께 적재해 이론상 최대 1core에서 2thread 효과가 나게 하는 기법이었다.
- 규약을 어느 선까지 지켜야하는가? 에 대해 고민해 보았다. 내가 생각하기에, 이건 순전히 프로토콜을 개발하는 개발자 마음인 것 같다. 물론 규약이라는게 다른 곳에서 우리 서비스를 사용하기 편하게, 통신 과정에 문제가 없게 지켜라 하는 것이지만 rfc에 적혀있는 모든 것을 미션 내에서 지키기에는 어렵다고 생각했다.
- 그래서 이번 주차는 일단 내가 꼭 필요하다고 생각하는 규약들만 지키고, 점차 확장해 나갈 계획으로 개발했던 것 같다.
- 이틀간에 걸친 코드 리뷰로 많은 사람들의 코드를 구경했는데, 다들 나보단 잘 짜신 것 같다.
- 다들 다음 주가 두려워 확장성 있는 코드에 고민을 많이 하신 모양인데, 나는 그만큼 다른 곳에서 공부를 했으니 이정도면 만족한다.
- 몇 번 테스트 해 본 결과 버그도 딱히 없는 것 같다!
- 일주일간 우테캠에 너무 적응을 잘하고, 많이 편해진 것 같다.
- 다들 일주일 동안 기술적으로 성장한 부분이 많이 없다고 하셨는데, 그 이유는 미션에 너무 집중하셔서 그런 것 같다.
- 나는 그래도 요구사항을 빨리 만족하고 개인 공부를 했기 때문에 기술적 성장이 좀 있는 것 같다.
- 이미지 업로드 및 CSV DB driver 구현이 주된 목표였다.
- 이미지 업로드 할 때 나는 DB에 byte array(BLOB) 형태로 이미지 데이터 자체를 저장하려 했다.
- 하지만 원하는 대로 되지 않았고, 디버깅하며 이유를 찾아냈다.
- 이유는 request로 온 byte[] 형태의 데이터를 new String() 하는 순간 데이터가 전부 망가져 버린다.
- 그래서 반드시 multipart 데이터를 byte[] 형태 그대로 조작하고, 파싱해야 한다.
- 저장할 때도 일반적인 String이 아닌 base64 url encoding을 적용해서 저장해야 한다.
- CSV에 대해 처음 알게 되었는데, 보통 ',' 단위로 컬럼을 구분한다고 한다.
- 그런데 데이터 중간에 ','가 껴있으면 어떡하지? 란 생각만 하고 예외 처리는 하지 못했다.
- 환경변수 입력, 설정 파일 등으로 data source를 변경하면 자동으로 h2 driver <-> csv driver로 바꿔끼는 작업을 하고 싶었는데 시간이 부족해 그러지 못했다.
- 이번 주는 동기화 관련해서 많은 고민을 하게 되었다.
- 실제로 동기화를 적용하는 코드는 그리 많지 않았지만, 자바의 동기화가 어떻게 적용되는지 그리고 운영체제에 어떤 영향을 미치는지 알아보았다.
- 운영체제 기본적인 동기화 방법
-
유닉스 계열의 운영체제는 POSIX thread라는 스레드 인터페이스를 구현하고 있다. 그래서 이 인터페이스의 메서드명이 전부 pthread_XXX 이런 식으로 돼 있다.
pthread_create(); pthread_mutex_init(); pthread_mutex_lock(); pthread_mutex_unlock(); pthread_cond_wait(); pthread_cond_signal();
-
스레드 라이브러리니까, 당연히 스레드 간 동기화 인터페이스도 지원을 하고, 각각의 운영체제는 이 인터페이스를 구현한다.
-
mutex
-
단일 스레드만 락을 걸고 임계 구역에 들어가고, 해당 스레드만 락을 해제하고 나올 수 있다.
-
우리가 이런 뮤텍스 동기화에 대해서 시스템 콜을 활용한다고 알고 있다. 시스템 콜은 다른 명령어와 달리 오버헤드가 있는 편이라 느리다고 알고 있다. 그래서 리눅스는 나름의 최적화 기법을 사용한다.
-
원래의 mutex는 반드시 시스템 콜을 호출해야 커널 내에서 락을 걸든, 풀든 할 수 있었다. 즉, 유저레벨에서 mutex를 호출하면 무조건 시스템 콜을 호출해야 한다는 것이다.
-
그런데 현재 linux에서는 lock을 futex라는 형태로 지원한다. 이 futex는 fast userspace mutex인데, 우리가 알고 있는 mutex는 시스템 콜인데 userspace라는 이름이 있는게 뭔가 이상하다. 이 futex는 시스템 콜을 최소화하기 위해서 사용되기 때문이다.
-
실제로 스레드가 wait, signal같은 스케줄링 큐에서 넣고 빼고 하는 커널 작업이 아니면 시스템 콜이 필요하지 않다.(lwp)
-
그래서 futex같은 경우에는 먼저 user space에서 락의 상태를 확인한다. 여기서 해제돼 있으면, user space에서 lock을 잠근다(mutex = true). 물론 이 과정은 어떤 방법을 통해서 원자적으로 실행될 것이다.
- 해제돼있지 않으면 그제서야 sys call을 호출해서 현재 스레드를 wait state로 만든다.
pthread_mutex_t mutex; pthread_mutex_lock(&mutex); pthread_mutex_unlock(&mutex); long futex(uint32_t *uaddr, int futex_op, uint32_t val, const struct timespec *timeout, uint32_t *uaddr2, uint32_t val3);
-
-
semaphore
- 여러 자원의 접근을 제어하는데 사용된다고 알고 있다. 그래서 counting semaphore라고도 불린다. 근데 semaphore의 count 수 만큼의 스레드가 critical section에 들어가서 작업을 한다? semaphore는 동기화 도구인데 이게 여러 스레드가 들어가서 작업을 한다는게 무슨 말일까?
- 세마포어는 뮤텍스와 다른 용도로 사용되는 것이다.
- 예를 들어서 db connection pool의 pool size를 10으로 해놓고 count 10짜리 세마포어를 생성해 사용할 수 있다.
- 대신, 각 connection에 스레드를 할당할 때는 mutex같은 상호 배제 도구를 사용해야 할 것이다.
- 그래서 한 자원에 여러 스레드가 접근하는게 아니라, 여러 여러 자원에 하나씩 스레드를 접근하도록 하는 것이다.
- 세마포어도 시스템 콜을 최적화 하는 방법을 사용하고 있다.
- 세마포어의 값을 감소, 증가 시키는 작업을 user mode에서 어떤 방법을 통해서 원자적으로 실행하고, 감소했을 때 sem 값이 0 미만이거나, 증가했을 때 sem 값이 0 이상이면 wait, signal 같은 커널 시스템 콜을 호출하게 된다.
-
-
CAS
- 우리가 mutex에서 lock의 값을 변경하는 것도 그렇고, semaphore에서 count를 감소시키는 것도 그렇고 원자적으로 실행될 것이라고만 했다.
- 뮤텍스의 lock의 값을 변경하는것을 코드로 표현하면 다음과 같다.
bool acquireLock(bool lock, bool expected, bool newValue) { bool temp = lock; if (lock == expected) { lock = newValue; } return temp; } .. while (acquireLock(lock, false, true) == true) { } // critical section..
- 원자적으로 실행된다는 것은 이 acquireLock 몇 줄이 하나의 트랜잭션 처럼 동작한다는 것이다. 이게 어떻게 가능한걸까? 바로 CAS라는 하드웨어 명령어를 통해 가능하다. tryLock이 CAS를 코드로 표현한 것이다.
- Compare And Swap은 어떤 값이 내가 알고 있는 값과 같을 때(lock이 false일 때), 새로운 값으로 바꾼다는 것(lock을 true로 변경)이다.
- 그래서 lock이 false(내가 알고 있는 값)일 때 true(새로운 값)로 변환하고 critical section에 진입하게 된다.
- lock free라고도 한다.
- 그런데 우리가 알던 cas 코드는 논리적인 흐름을 나타내기 위한 것임. 실제로는 cpu 명령어임
- 그래서 cpu마다 명령어가 조금씩 다를 수도 있다.
- AtomicInteger 클래스를 보면 모든 메서드가 native 코드를 호출하고 있고, 끝을 따라가보면 jvm은 위 그림과 같은 CAS 어셈블리 명령어를 호출하고 있음.
- jvm에서는 이런 식으로 파라미터에 명령어를 직접 바인딩해서 어셈블리어를 만들어냄
-
tcp 4-way handshake 과정에서 먼저 끊는 쪽을 Active closer라 하고, 반대를 Passive closer라 한다.
-
보다시피 Active closer쪽에 TIME_WAIT가 생성된다.
-
근데 이 소켓이 많아지면 문제가 발생한다.
- 먼저 로컬 포트 고갈이 된다. 왜냐하면 Active closer기 때문에, source port가 필요할 것이고 최대 포트는 65535개가 생성될 수 있기 때문이다.
-
그래서 이 많아지는 TIME_WAIT를 대응하는 방법이 몇 개 있다.
- net.ipv4.tcp_tw_reuse는 TIME_WAIT 소켓을 재사용한다?
- 근데 이 방법도 TIME_WAIT 소켓이 많이 생기는 것 자체는 해결할 수가 없다.
- tcp connection pool
- 미리 연결을 열어놓기 때문에 불필요한 TCP 3, 4way handshake가 없어서 빠르다.
- 근데 이것도 문제가 있다. -> 이후 keepalive
- net.ipv4.tcp_tw_reuse는 TIME_WAIT 소켓을 재사용한다?
-
근데 서버 입장에서 의 TIME_WAIT 소켓이 많이 생성되면, 서버는 소켓을 열어놓고 요청을 받는 입장이기 때문에 로컬 포트 고갈 같은 문제는 안일어난다. 근데도 3, 4 way handshake가 다수 일어날 수 있어서 느릴 수 있다.
- 근데 서버 입장에서 왜 로컬 포트 고갈이 안일어나지? 무조건 FIN 먼저 보내는 쪽이 Active closer가 돼서 source port가 붙은 소켓을 생성해야 하는 것 아닌가?
- 이게 3way, 4way handshake가 하나의 소켓으로 관리되고, 꼭 클라가 먼저 연결 해제하려고 하는게 아니라, 서버가 먼저 연결 해제하려고 할 수도 있다. 그니까 요청은 클라가 먼저 해서 클라에서 local port가 생겼는데, Active closer는 서버가 될 수도 있는 것이다. 오히려 이게 더 자연스럽다. 즉, 서버가 FIN_1을 먼저 보내게 된 것이다.
- keepalive를 사용할 수도 있다.
- keepalive를 설정하면 그 시간동안 연결을 끊지 않고 시간이 지나야 서버에서 먼저 연결을 끊기 때문에 3 way, 4 way가 좀 적어질 것이다.
- 근데 서버 입장에서 왜 로컬 포트 고갈이 안일어나지? 무조건 FIN 먼저 보내는 쪽이 Active closer가 돼서 source port가 붙은 소켓을 생성해야 하는 것 아닌가?
-
근데 TIME_WAIT는 왜 필요한 것인가?
- 연결 종료 시에 문제점을 방지하기 위해서이다.
- 만약 TIME_WAIT가 매우 짧다고 가정할 때, Passive closer쪽에서 FIN을 보내고, 그에 대한 ACK를 받아야 하는데 ACK가 유실됐다고 생각해보자. 그럼 FIN을 다시 보낼것인데 TIME_WAIT가 매우 짧아서 이미 Active closer의 소켓이 닫혀버린 상태이다. 그럼 Active closer쪽에서는 비정상적인 소켓이라 생각하고 RST를 보낸다.
- 그럼 Passive closer는 소켓이 계속 LAST_ACK 상태로 남게되는 것이다.
-
TCP keepalive는 두 종단 간에 설정된 세션을 일정 기간 유지하는 것이다.
-
명시적으로 연결을 끊기 전, 즉 Active closer 쪽에서 FIN_1을 보내기 전에 Keepalive라는 작은 패킷을 보내서 ACK가 오면, 계속 연결을 유지한다.
- 이게 두 종단 누구나 보낼 수 있고, 연결을 계속 유지하면 좋은 쪽에서 보통 먼저 보낸다.
- 근데 keepalive에 대한 ACK가 안오면 소켓을 끊는다.
-
keepalive는 3개의 파라미터를 통해서 구현된다.
-
net.ipv4.tcp_keepalive_time
- 이 기간 동안에는 최소 연결 유지하고, 기간 지나면 keepalive 패킷을 보낸다.
-
net.ipv4.tcp_keepalive_probes
- keepalive를 보낼 최대 횟수다.
-
net.ipv4.tcp_deepalive_intvl
- 재전송 주기다.
-
-
이걸 어디서 활용하나? 만약 클라-서버 구조에서 keepalive를 사용하고 서버가 이미 닫혔으면, 클라에서 어느 정도 이후 몇 주기로 몇 번 keepalive 패킷을 받는다. 마지막 패킷에 ACK가 오지 않았으므로, RST를 보낸 다음 소켓을 닫는다. 그래서 이걸 통해서 좀비 커넥션을 해결할 수도 잇다.
-
HTTP keepalive와 TCP keepalive
- nginx같은 곳에서의 keepalive timeout이라는 기능이 있는데 이건 HTTP keepalive이다.
- TCP Keepalive는 두 종단 간의 연결을 유지하기 위함인데, HTTP Keepalive는 최대한 연결을 유지하는 것이 목적이다? 이게 뭔 말이지?
- TCP Keepalive는 일정 간격으로 연결 유지를 확인하고, 응답 받으면 연결을 유지하지만 HTTP Keepalive는 일정 간격 유지하고, 이후에 요청이 없다면 연결을 끊는다.
-
인터럽트
- 운영체제는 부팅 시에 특별한 명령어를 실행하고, cpu에 어떤 테이블을 등록한다. 이 테이블은 함수 포인터들의 집합인데, 결국 A번째 인터럽트가 발생하면 0번째 인덱스의 함수 포인터를 찾아 그 함수를 실행하라는 것이다.
- 결국 그 함수는 인터럽트를 처리하는 핸들러가 되는 것이다.
- CPU가 프로세스를 실행하고 있을때, 하드웨어 장치나 예외 상황이 발생해 처리가 필요한 경우에 인터럽트를 걸어 처리할 수 있도록 한다.
- 이건 하드웨어 인터럽트에서 사용하는 방식이다.
- CPU는 명령어 사이클이 끝날 때마다 인터럽트 라인을 확인한다.
- 결국 그 함수는 인터럽트를 처리하는 핸들러가 되는 것이다.
- 소프트웨어 인터럽트도 비슷하지만, 좀 다르다. 사용자 프로세스는 시스템 콜을 통해서 운영체제에 핸들러(ISR)를 하나씩 등록(IDT)하는데 이 시스템 콜은 핸들러 함수의 포인터를 인자로 받는다.
- 그래서 나중에 운영체제가 해당 프로세스에 시그널을 보내야 한다고 인식하면(프로세스가 0으로 나누는 명령을 실행했는데 당연히 cpu가 그걸 처리 못하면) 그 프로세스가 실행될 때 운영체제는 프로세스의 명령어 포인터(0으로 나누는 명령어의 포인터)를 핸들러 함수의 시작점(인터럽트 처리 핸들러 포인터)으로 설정하고, 레지스터를 저장한 다음에 인터럽트 루틴을 처리한다.
- 그래서 소프트웨어 인터럽트는 cpu가 인터럽트 라인을 확인하지 않고, 이벤트 처리 하듯이 하는 것이다.
- mask
- 어떤 인터럽트를 마스크한다는 것은, 인터럽트를 비활성화 한다는 것이다.
- 대부분의 인터럽트 컨트롤러는 내부적으로 인터럽트 마스크 레지스터를 가지고 있는데, 이 레지스터를 통해서 특정 인터럽트를 활성/비활성화 할 수 있다.
- maskable interuupt: 마스크 가능 인터럽트
- non-maskable interrupt: 마스크 불가능 인터럽트. 굉장히 높은 우선순위를 갖는 인터럽트를 의미한다.
- IDT(interrupt descriptor table)
- 여러가지 인터럽트에 대해 해당 인터럽트 발생 시 처리해야할 루틴의 주소를 보관하고 있는 테이블을 의미한다. CPU는 IDTR이라는 레지스터에 이 테이블의 위치를 저장해 놓는다.
- ISR
- 인터럽트 핸들러라고도 하며 실제 인터럽트를 처리하는 루틴으로, 실행중이던 레지스터와 PC를 저장하고, 인터럽트 처리가 끝나면 원상태로 복귀한다.
- 모든 시스템 콜이 인터럽트라 할 수 있는가?
- 그렇진 않다.
- 거의 모든 시스템 콜이 인터럽트를 통해 실행되지만, 최근 리눅스 시스템 콜은 특정 cpu에서 제공하는 SYS_ENTER/SYS_EXIT을 통해서 인터럽트 없이 cpu 동작+레지스터 만으로 시스템 콜을 처리하기도 한다.
- CPU는 program counter가 가리키는 주소 공간의 명령어를 가리키고 있다. 그리고 다음 명령어를 수행하기 전에 인터럽트가 요청됐는지 확인한다(polling). 각 인터럽트 요청은 cpu의 인터럽트 라인에 세팅된다.
- cpu는 현재 실행 중이던 명령어까지 수행하고, PCB, PC를 저장한다.
- 인터럽트의 원인 판별 후, IDT를 참조해서 해당 인터럽트에 대한 ISR의 주소값을 얻는다.
- ISR이 동작하고, 실질 인터럽트 처리 작업을 한다.
- 만약 이 과정 중 우선순위가 더 높은 인터럽트가 발생하면 재귀적으로 앞의 과정을 수행하고, 다시 지금 과정으로 복귀한다.
- 상태 복구 명령어가 실행되면, 저장해둔 pc를 복원한다.
- HW interrupt
- 하드웨어가 발생시키는 인터럽트 또는 timer interrupt로, cpu가 아닌 다른 하드웨어 장치가 cpu에 어떤 사실을 알려주거나 cpu 서비스를 요청해야 할 경우 발생한다.
- 예를들어, 키보드를 누를때마다 키보드 컨트롤러는 cpu에게 인터럽트를 발생시킨다.
- 하드웨어 인터럽트는 프로세서의 클럭과 독립적으로 발생이 가능하지만, 결국 인터럽트 처리는 cpu가 하기 때문에 cpu 클럭에 의해 동기화된다.
- 또한 이런 특징 떄문에, 결국 cpu 입장에서는 명령어가 끝날때마다 인터럽트가 들어왔는지 확인하기 때문에 여전히 polling 방식이다.
- → 아니다. 폴링 방식은 인터럽트가 들어오든 말든 계속 라인을 확인하는 것이다. 뭔가 비슷해 보이지만 실제로 하드웨어 인터럽트가 발생하고 cpu가 인터럽트 라인을 확인할 때는 추가적인 CPU 사이클이나 명령어가 필요하지 않다. 폴링은 소프트웨어적으로 뭔가 주기적으로 상태를 확인하는 것이다. 그래서 하드웨어 인터럽트 라인을 확인하는 것은 폴링에 비해 거의 0에 수렴하는 오버헤드로 수행된다. 그니까 무조건 폴링이 아니고, “하드웨어 수준의 폴링” 이라고 볼 수 있다. 소프트웨어 폴링과 근본적으로 다른 메커니즘이다.
- 하드웨어로부터 입력된 데이터는 하드웨어 컨트롤러에 의해서 각 하드웨어 버퍼에 이동하는데, 전송할 데이터가 많으면 오버헤드가 커진다. 그래서 DMA 컨트롤러라는 하드웨어를 사용한다.
- DMA는 메모리에 직접 접근이 가능하다.
- 그래서 DMA가 메모리에 직접 하드웨어로부터 입력된 데이터를 넣고, 다 넣은 뒤에 CPU에 한 번의 인터럽트만 발생시킨다.
- DMA가 없으면 cpu가 i/o를 사용할 때 일반적으로 작업 전체 기간 동안 다른 작업을 수행할 수 없다.
- 만약 출력 상황에서 cpu가 i/o 명령어를 사용하면 i/o 작업이 수행되는 동안 cpu는 다른 작업을 수행하고 마지막으로 작업이 완료되면 DMA 컨트롤러에서 인터럽트를 받는다.
- 그래서 i/o가 느린 경우에 유용하다.
- 근데 DMA는 메모리에 직접 쓰기 때문에 cache 일관성 문제를 일으킬 수 있다. 이때 cache coherence 프로토콜 등이 사용된다.
- SW interrupt
- 예외 상황, 시스템 콜 등 소프트웨어가 스스로 인터럽트 라인을 세팅해 인터럽트를 발생시킨다.
- oom
- divide by zero
- system call
- page fault
- 예외 상황, 시스템 콜 등 소프트웨어가 스스로 인터럽트 라인을 세팅해 인터럽트를 발생시킨다.
- 메모장을 켜고 키보드에서 A를 눌렀을 때 일어나는 일을 상세히 알아보자.
- A를 누르면 키보드 스위치가 닫히면서 키보드 컨트롤러(하드웨어)에 A라는 키가 눌렸다는 전기 신호가 전달된다. 그럼 키보드 컨트롤러는 이를 감지하고 키보드 컨트롤러가 CPU에 인터럽트를 보낸다.
- 여기서 키보드 컨트롤러는 버퍼를 하나 들고 있는데 버퍼가 사용되는 이유는 cpu가 엄청 바빠서 하드웨어 인터럽트를 빨리빨리 처리하지 못할 때, 버퍼에 일시저장 해뒀다가 한번에 끌고오기 위해 사용된다.
- 그래서 만약 A 하나만 누르면 버퍼에 하나만 차고, 즉시 flush 될 것이다.
- 근데 여기서 인터럽트는 A라는 문자를 전달해야한다는 인터럽트가 아니라, 키보드 인터럽트가 발생했다는 그 자체의 인터럽트인 것이다.
- cpu는 idt를 참고해 isr을 찾는다.
- 키보드 인터럽트 isr은 키보드 입력에 대한 인터럽트를 처리해야 하기 때문이 키보드 드라이버가 필요할 것이다. 그러면 운영체제의 키보드 드라이버가 실행된다. 이때 A라는 값을 읽어오는 것이다. 읽어와서, 운영체제의 입출력 버퍼에 임시 저장된다.
- 이 입출력 버퍼에서 문자A로 변환되고, 운영체제의 또 다른 형태의 버퍼인 입력 큐에 추가된다.
- 그럼 운영체제가 메모장 프로세스에 키 입력 메시지를 보내고 이때 당연히 A라는 정보도 함께 보낸다. 그리고 메모장 프로세스는 메모리에 A를 적재한다.
- 그러면 운영체제 그래픽 시스템이 동작해서 여기도 버퍼링을 한 뒤에 실제 모니터에 표시한다.
- 여기서 운영체제의 입출력 버퍼와 입력 큐를 따로 쓰는 이유는 역할이 다르기 대문이다.
- 입출력 버퍼는 진짜 하드웨어와 직접 상호작용하는 저수준 처리를 담당하고, 입력 큐는 운영체제 수준에서 입력을 관리하고 분배하는 고수준 처리를 담당한다.
- 그래서 입출력 버퍼는 하드웨어와 직접 데이터 교환을 해서 저장하고, 입력 큐는 처리된 입력 데이터를 프로세스 레벨에서 사용 가능한 형태로 관리한다.
- 그래서 입출력 버퍼는 하드웨어를 추상화한 것이고, 입력 큐는 애플리케이션 계층에 가까운 추상화를 제공한다.
- A를 누르면 키보드 스위치가 닫히면서 키보드 컨트롤러(하드웨어)에 A라는 키가 눌렸다는 전기 신호가 전달된다. 그럼 키보드 컨트롤러는 이를 감지하고 키보드 컨트롤러가 CPU에 인터럽트를 보낸다.
- PIC(programmable interrupt controller)가 사용되는데, 여러 장치들이 서로 인터럽트를 요청하게 되는 상황에서 PIC가 요청들에 대해 우선순위를 매기며 인터럽트들을 관리하게 된다.
- 운영체제는 부팅 시에 특별한 명령어를 실행하고, cpu에 어떤 테이블을 등록한다. 이 테이블은 함수 포인터들의 집합인데, 결국 A번째 인터럽트가 발생하면 0번째 인덱스의 함수 포인터를 찾아 그 함수를 실행하라는 것이다.
프로세스 스케줄링 알고리즘
- FCFS
- 가장 간단함
- cpu burst 시간대에 따라 성능차가 클 수 있음
- convoy effect
- 사실 쓸모없어 보이지만, 배치 처리나 단순 임베디드 시스템 같은 모두 우선순위가 낮거나, 모두 우선순위가 높으면서 빠르게 끝나는 작업에 사용하면 좋을 것 같다.
- 또한 네트워크 라우터에서 fifo 형태로 패킷을 관리하는 것으로 알고 있다.
- SJF
- 가장 빨리 끝나는걸 먼저함
- RR
- FCFS와 유사하지만, time slice가 걸려있음
- timer interrupt
- RR은 preemption을 허용하는데, 이 선점 스케줄링 방식은 time slice를 다 소진하면 interrupt가 발생해 context switching이 일어나게 된다.
- 그래서 time slice가 너무 짧으면 프로세스 응답성이 좋아지고 뭔가 공평해 지는 것 같다. 하지만 잦은 context switching으로 인한 overhead가 증가하고, cache 효율성도 저하될 것이다. 또한 context switching이 자주 일어나기 때문에 전체 throughput이 감소한다.
- time slice가 너무 길면 context switching 오버헤드가 감소하고, 캐시 효율성도 좋고, 처리량도 높아질 것이다. 하지만 응답 시간이 저하되고 실시간 처리에 부적합하다.
- 그래서 cpu bound 프로세스들이 많은 경우 이게 유리할 수 있지만, i/o bound 프로세스들이 많은 경우 불리할 수 있다.
- i/o 작업은 i/o interrupt를 처리하는 시작점과, i/o가 끝났다고 알리는 마무리 interrupt 작업에만 cpu가 필요하고 나머지는 하드웨어가 알아서 하기 때문에 cpu가 그리 많이 필요하지 않다.
- 여기서 시작 인터럽트는 소프트웨어 인터럽트가 될 것이고, 마무리 인터럽트는 하드웨어 인터럽트 일 것이다.
- i/o 작업은 i/o interrupt를 처리하는 시작점과, i/o가 끝났다고 알리는 마무리 interrupt 작업에만 cpu가 필요하고 나머지는 하드웨어가 알아서 하기 때문에 cpu가 그리 많이 필요하지 않다.
- 그래서 cpu bound 프로세스들이 많은 경우 이게 유리할 수 있지만, i/o bound 프로세스들이 많은 경우 불리할 수 있다.
- 만약 싱글 스레드에서 상시로 돌아가야 하는 프로세스가 있다면 이걸 쓸 수 있을까?
- RR을 조금 변형한다.
- 이 상시로 돌아가야 하는 프로세스에 높은 우선순위를 주고, 우선순위가 높을 수록 time slice를 많이 할당하는 방식을 사용한다.
- 그래서 이 프로세스 뿐만 아니라 다른 프로세스들에게도 최소한의 시간을 보장할 수 있도록 한다.
- RR을 조금 변형한다.
- priority scheduling
- 같은 우선순위는 FCFS로 스케줄링
- starvation 발생 가능
- aging으로 해결
- multi-level queue
- 여러개의 큐로 나누어서 우선 순위가 높은 큐에는 보통 RR 방식으로 하고, 낮은 큐에는 FCFS로 각 큐를 스케줄링함.
- 우선순위가 높은 큐에는 빠른 응답을 줘야 하기 때문에 RR 방식으로 하고, 낮은 큐에는 별로 응답이 빠르지 않고, 그냥 스케줄링이 되기만 하면 되기 때문에 FCFS 같이 간단한 것을 사용하는 것으로 보임.
- 근데 여기서 하나의 cpu에 동시에 여러개의 프로세스가 스케줄링 될 수는 없으니, 어떤 큐에서 스케줄링 된 프로세스를 cpu에 할당할 지도 정해야함. 즉, 큐 간 스케줄링도 필요한데 여기서는 2가지 정도 방식이 있음.
- 첫 번째 방식은 무조건 우선 순위가 높은 큐에서 작업을 다 하면 그 다음 우선순위를 가진 큐에서 프로세스를 빼내는 것임.
- 근데 이렇게 하면 맨 밑에 프로세스들은 기아 현상이 발생할 수 있음.
- 두 번째 방식은 큐 마다 cpu를 일정 비율로 할당하는 것임.
- 그래서 높은 우선 순위 큐에서는 많이 할당하고, 낮은 큐에는 적게 할당하는 것임.
- 첫 번째 방식은 무조건 우선 순위가 높은 큐에서 작업을 다 하면 그 다음 우선순위를 가진 큐에서 프로세스를 빼내는 것임.
- 여러개의 큐로 나누어서 우선 순위가 높은 큐에는 보통 RR 방식으로 하고, 낮은 큐에는 FCFS로 각 큐를 스케줄링함.
- 보통 background, batch 같은 것들이 우선순위가 낮음
- multi-level feedback queue
- multi-level queue의 단점인 starvation을 해결하기 위해 큐 간 프로세스 이동이 가능해짐.
- 오래 기다린 프로세스들은 aging으로 높은 우선 순위를 가진 큐로 이동할 수 있음 → aging
- 또한 반대로 많이 실행된 프로세스는 우선순위를 낮춰서 하위 큐로 이동할 수도 있음.
- 동시성은 두 개 이상의 작업이 중복되는 시간대에 시작, 실행, 완료될 수 있는 경우를 말한다. 단일 코어에서 멀티 태스킹을 하는 경우를 의미하는데, 두 작업이 반드시 같은 순간에 실행되는 것은 아니다.
- 내가 이해한 바로는 단일 코어에서 1번이 시작하고 1번이 끝나고 2번이 시작하고 2번이 끝나고 이런게 아니다.
- 1번이 시작하고 1번이 일시정지하고 2번이 시작하고 2번이 일시정지하고 1번이 재개하고 1번이 끝나고 2번이 재개하고 2번이 끝나고 이런것이다.
- 병령성은 말 그대로 동시에 작업이 실행되는 것으로 멀티 코어 프로세서에서를 의미한다. 최소 두 개 이상의 스레드가 동시에 실행되는 경우 발생하는 조건이다.
- http://docs.oracle.com/cd/E19455-01/806-5257/6je9h032b/index.html에 따르면 동시성은 최소 두 개의 스레드가 진행 중일 때 존재하는 조건으로, 가상 병령성의 한 형태라고 한다(뭔가 그냥 밖에서 봤을 때는 병렬적으로 동시에 실행한다고 보일 수 있기 때문이다?). 그리고 time slice를 포함할 수 있다고 하니까 이게 일시정지, 재개 이런 것이 가능하다는 것이다.
- 사실 리눅스에서는 user thread-kernel thread를 1:1로 매핑하기 때문에 프로세스와 스레드의 경계가 희미하고, 이를 모두 task라 부른다.
- 리눅스 스케줄러는 “예약 가능한 작업” 단위로 스케줄링 하는데 여기서 예약 가능한 작업은 다음과 같다.
- 단일 스레드 프로세스(1개의 task)
- 멀티 스레드 프로세스 내부의 모든 스레드(여러개 thread(task), 특히 pthread)
- 커널 작업 스레드
- 그래서 멀티 스레드 프로세스 내부 모든 스레드도 단일 스레드 프로세스처럼 스케줄링된다. 왜냐하면 pthread를 생성하기 위해서는 pthread_create()를 해야하는데, 이는 내부적으로 clone() 이라는 시스템 콜을 사용한다. 근데 리눅스에서 실제로 스케줄링 되는 단위가 clone()의 반환값이다.
- Linux 커널의 경우 스레드 개념이 없다. 각 스레드는 커널에서 별도의 프로세스로 간주되지만 이러한 프로세스는 다른 일반 프로세스와 다소 다르다.
- 스레드는 lwp라는 용어와 혼동되는데, linux 옛날 버전에서는 linux가 사용자 수준 스레드만 지원했다. 즉, 애플리케이션에서 멀티스레딩으로 여러 스레드 만들어봤자 커널에서는 이걸 몰라서 단일 프로세스로만 간주됐다.
- 근데 nptl 이후로 각 스레드에 프로세스 정보가 첨부돼서 커널이 이를 처리할 수 있게 되었다(리눅스는 동일한 프로세스에 속한 스레드들을 ‘스레드 그룹’으로 관리한다). 하지만 앞서 말했듯이 커널은 스레드라는 개념이 없고 커널 프로세스라는 개념만 있다. 즉, 애플리케이션에서 여러 스레드를 만들면 커널은 각각을 커널 프로세스라 보고 이러한 프로세스를 경량 프로세스(LWP)라고 하는 것이다.
- LWP와 LWP가 아닌 프로세스의 차이점은 LWP가 동일한 주소 공간과 open file table 같은 리소스를 공유한다는 것이다. 익히 알고 있는 스레드의 개념과 동일하다. 리소스가 공유되므로 가벼운 것으로 간주된다. 그래서 스레드를 LWP라고도 불리는 것이다.
- 그래서 리눅스에서 일반 프로세스를 생성하려면 fork()를 사용해서 clone()을 추가로 호출하는 반면, 스레드(LWP)를 사용하려면 pthread_create를 호출하고 이는 clone()을 내부적으로 호출한다. 근데 LWP 생성 시에 clone()할 때 fork 이후 clone()하는 것과는 다른 플래그를 파라미터로 넘겨서 부모 프로세스의 리소스를 공유한다.
- 뮤텍스와 세마포어 모두 공유자원에 대한 접근을 제어하기 위한 동기화 메커니즘이다.
- 뮤텍스: 한 번에 하나의 스레드만 공유 자원에 접근할 수 있도록 제한하는 동기화 메커니즘이다.
- 임계구역 접근을 위해 lock, 임계구역을 빠져나올때 unlock을 사용함
- 그래서 lock이 걸려있는 곳에 다른 스레드가 접근할 때 spin lock 기법으로 계속 락이 풀리길 기다리며 확인한다.
- spin lock은 lock이 풀릴때까지 계속 확인하며 대기하는 기법임. 컨텍스트 스위치가 일어나지 않고, 락이 풀리는 시간이 짧을 걸로 예상되는 경우 효율적임
- 당연히 대기 시간 길어지면 cpu 계속 쓰니까 안좋음
- Adaptive Spin Lock: spin lock 대기 시간이 어떤 임계 값을 초과하면 프로세스를 슬립 상태로 전환해서 CPU 자원 낭비를 줄이는 방식으로 해결함.
- 이걸 리눅스에서는 futex라는 것으로 구현해 놨는데, fast user mutex라는 것으로 뮤텍스를 유저스페이스에서 관리하겠단 뜻이다.
- 실제로 뮤텍스라는 락 변수를 프로세스끼리 공유할 수만 있다면 wait, signal 연산을 하는 것 말고는 커널의 도움이 사실 필요하지 않다.
- 그래서 프로세스 간의 경합이 적은 경우에는 실제로 spin lock을 돌면서 락이 풀리길 대기하다가, 경합이 많아지면 FUTEX_WAIT 시스템 콜을 호출해서 스레드를 스케줄링 대상에서 제외시켜 버림.
- 세마포어: 공유 자원에 대한 동시 접근 수를 제한하는 도구로, 세마포어의 값만큼 스레드가 동시에 접근할 수 있음
- 공유 데이터를 여러 프로세스가 동시에 접근할 때 잘못된 결과를 만들 수 있기 때문에, 한 프로세스가 공유 데이터를 여러 프로세스가 접근하지 못하도록 막아야 함
- 근데 이진 세마포어와 뮤텍스는 뭔가 똑같아 보인다. 하지만 세마포어와 뮤텍스는 역할 자체가 다르다는 것을 기억해야 한다.
- mutex는 공유 자원을 보호하기 위한 목적이다. 그래서 뮤텍스는 해당 작업을 수행하는 task(thread)에 의해 소유된다. 하지만 이진 세마포어는 세마포어를 가지고 있는 A라는 작업이 B에게 세마포어를 주는 것이 가능하다. 뮤텍스는 주는 개념이 아니라, 자기가 critical section을 나오는 것이다. 즉, 이진 세마포어는 자원에 대한 접근을 보호하는 목적이 아니다.
- 정리하자면, 뮤텍스는 철저히 자원에 대해 접근을 배제하는 것이고, 이진 세마포어는 동기화 목적, producer-consumer 같은 곳에서 사용되는 것이다. 그니까 세마포어는 스레드간에 안정적인 통신을 통해서, 스레드의 작업을 순서화하는 것이다.
- 이진 세마포어와 뮤텍스는 다른 의도를 가진 동기화 메커니즘이다. 뮤텍스는 자원에 대한 독점적인 접근을 위한 것이고 이진 세마포어는 동기화를 위해 사용된다.
- 뮤텍스는 공유 자원을 보호하기 위해 사용된다. 뮤텍스는 이 자원을 사용하는 작업에 의해서 소유된다. 만약 A라는 작업이 가지고 있는 뮤텍스를 B라는 작업이 해제하려고 하면 실패한다.
- 이진 세마포어는 완전히 다르다. 이진 세마포어 경우 B가 세마포어를 가져오고 A가 이 세마포어를 놓아주는 것은 괜찮음.
- mutex는 공유 자원을 보호하기 위한 목적이다. 그래서 뮤텍스는 해당 작업을 수행하는 task(thread)에 의해 소유된다. 하지만 이진 세마포어는 세마포어를 가지고 있는 A라는 작업이 B에게 세마포어를 주는 것이 가능하다. 뮤텍스는 주는 개념이 아니라, 자기가 critical section을 나오는 것이다. 즉, 이진 세마포어는 자원에 대한 접근을 보호하는 목적이 아니다.
- 뮤텍스와 세마포어 모두 시스템 콜을 호출해야 한다.
- 시스템 콜을 호출해서 커널이 락을 관리하기 때문에 개발자가 뮤텍스, 세마포어만 사용하면 안전하게 동기화가 가능하다.
- 하지만 시스템 콜 자체는 오버헤드가 크고, 그래서 리눅스는 이걸 최소화하기 위해 FUTEX 라는 개념을 도입했다.