위 링크에 따르면 가장 좋은 해결책은 실행 가능한 스레드의 개수를 하드웨어 스레드의 개수에, 가능하다면 외부 캐시에 맞추어 제한하는 것이라고 한다. 네 개의 하드웨어 스레드와 두 개의 외부 캐시를 지원하는 CPU가 있다면 실행 가능한 스레드 네 개를 모두 사용하는 것이 최선이라고 한다.
추가적으로 입출력 스레드와 계산 스레드를 분리하는 것이 도움이 된다고 한다. 계산 스레드는 대부분의 시간에 실행 가능한 스레드이고, 외부 이벤트로부터 차단되지 않고 항상 작업 큐로부터 일거리를 받아 계속 뭔가를 실행하는 스레드이다. 그래서 계산 스레드의 개수는 프로세스 자원에 맞춰야 한다.
I/O 스레드는 대부분의 시간을 외부 이벤트 때문에 대기하는 스레드이다.
계산 스레드를 위한 효과적인 작업 큐를 구축하려면 스레드 풀을 이용하는 방법이 있다. 스레드 풀을 이용하면 각자 이용가능한 스레드별로 작업을 나누어 가지면서 효율적으로 작업을 처리할 수 있다.
비동기 프로그래밍을 시작하면 가장 많이 듣는 단어가 promise, future와 같은 추상적인 단어들이다. 직역하면 약속, 미래인데 갑자기 무슨 뚱딴지 같은 소리인가 싶었다.
비동기의 작동 방식을 이해하면 각 변수의 이름이 왜 그렇게 지어졌는지를 알 수 있다.
동기식으로 작동하는 경우에는 A라는 주체에서 B라는 주체에 어떤 작업을 요청하면, 요청한 작업이 완료되어 결과가 리턴될 때까지 기다려야 한다. 일반적으로 이 경우에 호출한 함수는 결과를 기다려야 하기 때문에 blocking 된다. 즉, A와 B는 순차적으로 실행된다. 이 경우를 동기적으로 실행된다고 볼 수 있다.
반면에 비동기식으로 작동하는 경우에는 A가 B에게 어떤 작업을 요청하면 B는 우선 작업이 완료되지 않았지만 A에게 리턴해준다. 그럼 A는 제어권을 가지고 우선 자기가 할 수 있는 일이 있다면 진행하고 있다가,
1) B에게 완료되었는지 지속적으로 확인한다
2) B가 자신의 작업이 완료되면 A에게 알려준다.
둘 중 하나의 방법으로 그때서야 결과를 전달받아 요청한 결과를 바탕으로 작업을 이어나간다.
비동기 방식을 잘 보면 우선 B가 A에게 리턴은 해주었지만 당장 결괏값을 준 것이 아닙니다. 그럼, 무슨 값을 전달해 준 것일까요? 이 전달해준 것이 우리가 Promise, Future 등으로 부르는 객체입니다. 현재는 값이 담겨져있지 않지만, 나중에 이 객체에 값을 넣어서 전달해주겠다는 약속/미래의 값 이라는 의미입니다.
호출한 입장에서는 값을 불러오는 명령을 통해 전달받거나, 콜백을 통해 전달받는 식으로 호출한 시점으로부터 미래에 이 값을 받을 수 있습니다.
그러나 일반적으로 future와 promise의 차이점이 있습니다. future는 외부로부터 값이 들어오기만 하기 때문에 read-only이지만, promise는 강제로 내가 complete할 수 있는 메서드를 제공합니다. (completable하다) 즉, java에서 제공하는 CompletableFuture는 Promise와 같이 강제로 완료시킬 수 있는 complete 메서드를 지원하는 객체입니다.
추가적으로 별도로 스레드를 생성하지 않고 비동기 작업을 수행할 수 있고, CompletionStage의 구현체이므로 비동기 작업을 의존적으로 또 다른 기능을 수행할 수 있습니다. (완료되면 이거 실행하고, 그거 완료되면 저거 실행하고 이런식으로 가능해집니다) 또, 파이프라인식으로 코드를 사용할 수 있어 콜백 지옥에 빠지지 않습니다.
서로 다른 객체끼리 결합도가 높을수록 어느 한 쪽이 변경되면 다른 한 쪽도 그에 따라서 바뀌어야 한다. 예를 들어 메인보드를 구매했는데 CPU 소켓과 CPU 사이에 강한 결합도를 가지면, CPU를 업그레이드하려면 메인보드까지 같이 사야한다. (Intel.....) 하지만 AMD의 예를 들면 세대를 거듭하면서도 동일한 메인보드로 CPU만 교체하는 것이 가능하다. 물론 100% 일치하는 비유는 아니지만, 이처럼 1:1 로 결합하는 경우에는 변경에 매우 취약해진다.
객체를 인터페이스 / 구현으로 나누어서 세부적인 구현은 감추고 인터페이스만을 드러내는 것이다. CPU 비유를 이어서 해보면, 우리 메인보드 소켓은 A 인터페이스와 호환된다. 라고 하면, CPU 제작 업체에서는 A 인터페이스만을 지키면서 내부 사양은 업그레이드할 수 있는 것이다. 즉, 의존성이 매우 떨어지는 것을 볼 수 있다. 메인보드의 사양이 특정 제품에서 어떤 공통 인터페이스를 가졌기 때문이다. 인터페이스란, 이름에서도 알 수 있듯이 서로 다른 face간의 사이(inter)를 의미한다. 서로 다른 객체 사이에서 완충재 역할을 하여 변경 시에 좀 더 유연한 대처를 가능하도록 해준다.
자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도는 낮추고 응집도를 높일 수 있다. 어떤 모듈 내의 요소들이 하나의 목적을 위해 긴밀하게 협력한다면 그 모듈은 높은 응집도를 가진다. 응집도를 검사하는 좋은 방법은 모든 메소드들에 대하여 인스턴스 변수를 사용하는 비율이 높은지 보는 것이다. 어떤 클래스의 응집도가 높다면 메소드와 변수가 서로 의존하고 있을 것이고, 낮다면 상태와 기능의 논리적 연결이 약할 것이고, 이는 클래스를 더 분리할 수 있음을 암시한다.
동기 방식으로 동작하는 함수는 순차적으로 실행이 된다. A가 B를 호출하고, B가 C를 호출하면 A->B->C로 이어지는 흐름이 보이기 때문에 흐름에 따라 중단점을 설정하면서 디버깅을 할 수 있다. 하지만, 비동기로 동작하는 경우에는 동시에 다른 스레드에서 작업이 진행되기 때문에 디버깅이 어려울 것 같다. 다른 스레드의 동작에 따라 실행 결과가 바뀔 수도 있기 때문이다.
인텔리제이에서 비동기 방식의 코드를 디버깅 하는 방법을 올려둔 글을 보았다. 만일 비동기적으로 동작하는 코드에 중단점을 올려두고 디버깅하면, 스레드 별로 수행되는 결과를 볼 수 있도록 보여준다.
멀티 스레드 환경으로 프로그램을 동작하는 경우에, 서로 다른 스레드가 공유 자원에 동시에 접근하여 자원을 변경할 수 있다. 이를 Race condition이라고 한다. 흔히 드는 예로, 입/출금 서비스가 있다고 할 때 서로 다른 스레드가 동시에 접근하여 출금/입금을 했는데 조회하는 시점이 동일하여 어느 한 쪽의 결과만 반영되는 결과가 생길 수 있다.
이는 DB의 트랜잭션에서도 비슷한데, 공유 자원에 접근하는 경우에 이런 동기화 문제를 피하기 위해 락(Lock)을 걸어야 한다.
스레드 간에 락을 거는 방법은 어느 스레드가 critical section에 접근하는 경우 다른 스레드가 접근할 수 없도록 하거나 권한을 제한하는 방법을 사용한다. 여기서 critical section이란 공유 데이터에 접근하는 코드 부분을 뜻한다.
Lock의 방법의 하나로 세마포어가 있다. 주로 공유 자원의 개수가 유한 개일 때 관리하는 방법으로 쓰인다. 실생활을 예로 들면 도서관의 대출 시스템이라고 볼 수 있다. 각 스레드는 공유 자원이 필요할 때마다 세마포어를 요청하고, 남은 세마포어가 있다면 객체는 공유자원을 이용하고 작업이 끝나면 반납한다. 만약, 남은 세마포어가 없다면 스레드는 세마포어에 자리가 생길 때까지 대기한다. 이 과정에서 busy-wait 또는 sleep lock 등의 대기 방식이 있다. 자원을 기다리는 동안 해당 프로세스를 block 시키거나(sleep lock) 권한에 빈 자리가 생길 때까지 지속적으로 확인하는 방식(busy-wait)이다.
뮤텍스는 Binary semaphore라고도 불리며, 이름에서도 알 수 있듯이 0과 1 두 가지의 상태를 가지는 lock이다. 즉, 한 스레드가 임계 영역에 진입하면 lock을 걸고, 빠져나갈 때 unlock해줌으로써 동시에 한 스레드만 임계 영역에 접근하도록 하는 방법이다.
앞의 두 방법은 개발자가 직접 공유 자원을 제어해야 하는 방식이다. 그렇기 때문에 구현하기 어렵고, 제대로 구현되었는지 검증도 힘들다. 자바와 같은 언어에서는 이러한 과정을 synchronized
라는 키워드를 통해 지원해준다. 알아서 해당 키워드가 붙은 영역에 대해 상호 배제를 실현해주고, 대기하기 위한 queue 구조 등을 지원하므로 개발자가 직접 임계 영역을 신경쓸 필요가 없다.
지금까지 배운 내용을 보면 멀티 스레드를 활용한 비동기 방식을 사용하면 절대적으로 장점만 있는 것처럼 보인다. 동시에 많은 작업을 여러 스레드를 통해 작업한다면 무조건적인 성능의 이득을 가져갈 것으로 보인다. 하지만 스레드를 너무 많이 생성하면 오히려 단점이 생긴다.
자바의 예를 들자면, 당연히 스레드를 생성하고 제거하는 작업에도 자원이 소모된다. 클라이언트의 요청을 처리할 때 계속해서 스레드를 생성해야 하기 때문에 딜레이가 발생하고, 만약 요청이 간단하면서 자주 발생하는 유형이라면 요청이 들어올 때마다 새로운 자바 스레드를 생성하는 일이 요청을 처리하는 일보다 더 커질 수 있다.
멀티 프로세스 대비 멀티 스레드는 프로세스 내부의 메모리 영역을 공유하므로 메모리에 효율적이다. 하지만 너무 많은 스레드는 오히려 자원을 낭비한다. 프로세서 대비 많은 스레드가 동작중이라면 실제로는 많은 스레드는 idle 상태로 낭비되고 있다는 뜻이다. 또한 CPU 자원을 서로 경쟁하는 상황이 되어 추가적인 자원을 소모하게 된다.
스레드가 너무 많이 생기게 되면 Out of memory
현상이 발생할 수 있다.
동시성 프로그래밍은 병렬성과 달리 오랜 시간이 걸리는 작업이 있더라도 동시에 처리하면서 응답성을 높이는 프로그래밍 방식입니다. 병렬적으로 수행된다는 것은 모든 작업을 실제로 각자가 동시에 처리하는 것이지만 동시성은 여러 작업을 한 번에 다루기 때문에 동시에 실행되는 것처럼 보이지만 실제로는 번갈아가며 실행되고 있습니다.
동시성 프로그래밍의 중요성이 커진 것은 하드웨어의 발전이 더뎌지기 시작하면서입니다. CPU의 클럭을 무한정 발전시킬 수 없는 한계에 봉착하였고, 멀티코어 형태로 CPU를 발전시켜 나가기 시작합니다. 그러면서 CPU가 다룰 수 있는 스레드의 개수가 늘어남에 따라 병렬성/동시성에 대한 중요성이 늘어났습니다. 즉, 하드웨어로 극복할 수 없는 성능의 향상을 소프트웨어적인 방법으로 극복하려는 움직임이라고 볼 수 있습니다.
동시성 프로그래밍의 장점은 순차적으로 한 작업이 완료되기까지 기다리는 것이 아니라 순차적으로 실행되며 사용자로 하여금 동시에 여러 작업이 실행되는 것같은 착각을 주어 반응성을 증가시킵니다. 동시에 적은 스레드로 다중 작업을 효과적으로 처리할 수 있기 때문에 효율적이라는 장점이 있습니다.
객체 설계
결합도와 응집도는 무엇일까?
비동기 프로그래밍
Debug asynchoronous code
자바 스레드 문제점 3가지
동시성 소개
글 정말 좋네요 :) 잘 읽었어요!