서비스는 하나인데 어떻게 여러명이 동시에 접속할 수 있을까?
서버가 멀티 쓰레드로 동작하기 때문이다.
나는 쓰레드를 따로 계속 생성해서 개발한 적이 없는데 어떻게 멀티 쓰레드로 동작하지?
단일 쓰레드에서 동작하는 것 처럼 개발해도 여러 사람들이 사용할 수 있는 이유는
WAS 에서 요청 별로 쓰레드를 생성해서 관리해주기 때문이다.
잘 생각해보면, WAS tomcat이나 jeus같은 프로그램에서 우리는 쓰레드 풀을 설정 했었다.
WAS에서 요청이 올 때마다 우리가 설정한 쓰레드 풀에서 쓰레드를 하나씩 꺼내서 사용할 수 있도록 해준다.
그럼 왜 쓰레드 풀에 담아놓고 사용하는 걸까?
결과적으로 말하자면, 꽤 부담이 큰 작업이기 때문에 그렇다.
프로그램 내에서 쓰레드를 하나 생성할 때 일어나는 JVM 내부적인 변화는 뭐길래 부담이 큰 작업이라고 하는 것이냐,
메모리 할당
JVM은 해당 쓰레드가 사용할 로컬변수가 저장되는 새 스택과 데이터 세그먼트를 할당한다.
Scheduling
JVM은 스케줄러에 새 쓰레드를 추가하고 스케줄러는 각 쓰레드가 실행되어야 하는 시기를 결정하고 각 쓰레드에 시간을 할당한다.
해당 메모리를 할당받고 해당 쓰레드를 스케줄러에 등록하는 등의 꽤나 무거운 작업을 진행한다.
또 반납하는 작업까지 거쳐야한다. 매 요청마다 이걸 반복한다고 생각해보자. 엄청난 성능 저하가 발생할 것이다.
그래서 미리 생성해 둔 Pool에 담아 놓고 필요할 때마다 사용하고 반납하는 작업을 진행한다.
이렇게 되면 생성 및 소멸에 대한 오버헤드가 줄어들고 부하가 높은 경우에도 일관된 수준의 성능을 보장하는데 도움이 될 수 있다.
그렇다고 너무 큰 풀을 생성하게 되면 많은 작업을 동시에 처리할 수 있지만 더 많은 시스템 리소스를 낭비할 수 있다.
또 너무 작은 풀은 더 효율적일 수는 있지만 요청이 많이 들어올 경우에 해당 처리를 따라잡지 못할 수 있다.
이건 서비스에 따라 적절하게 설정하는 것이 중요하다.
대표적인 Was Tomcat9.0의 기본 쓰레드 풀은 200개, Spring boot max 200개까지로 설정되어 있다.
어? 생각해보니 근데 CPU의 Core가 처리할 수 있는 쓰레드는 1개로 알고 있는데?
Core가 8개인 서버라면 한 번에 8개의 요청만 처리할 수 있는거 아닌가?
근데 200개의 쓰레드 풀을 만들어 놓고 쓰레드를 동시에 처리할 수 있을까?
예~전에는 1코어=1쓰레드가 당연했지만 최근에는 물리적으로 하나인 코어를 논리적으로 둘로나눠 전체 코어수가 2배로 늘어난 것과 유사한 효과를 보는 SMT 기술이 적용된 CPU가 많다. 인텔에선 SMT 기술을 하이퍼쓰레딩 기술이라고 부르기도 한다.
현재는 쿼드코어, 8코어 등등 갈수록 더 발전해서 나오는 중!
이를테면 MSI Sword GF66 노트북에 탑재된 11세대 코어 i7-11800H는 8코어를 품은 CPU지만 하이퍼쓰레딩이 적용되어 있기 때문에 운영체제에선 이를 16개의 쓰레드를 가진 CPU로 인식한다.
이는 논리적으로 나뉜 것일뿐 이기 때문에 1코어당 1쓰레드의 경우 111111 00000 으로 처리하지만 1코어당 2쓰레드로 동작할 경우 1010101010 로 처럼 일을 처리하기 때문에 마치 코어가 2개인것 처럼 보이는 것이다.
즉, 코어 수보다 쓰레드 수가 많을 경우 쓰레드는 코어를 서로 일정 시간동안 번갈아가면서 사용하게 되고,
그 과정에서 context switching(문맥 교환)이 발생하게 되는 것이다. 이때 각 쓰레드의 stack. 메모리를 CPU 캐시에 올렸다 내리는 작업을 계속하게 되기 때문에 이런 시간이 커질수록 멀티 쓰레드에 대한 효율이 떨어질 수 밖에없다.
결국 코어 수 보다 많은 쓰레드 들은 코어들이 굉장히 빠른 속도로 문맥교환을 통해 동시에 일어나는 것처럼 보일 뿐 실제로는, CPU 자원을 번갈아가며 사용하고 있는 것이다.
그렇다면, 많은 사람들이 사용하는 멀티 쓰레드 프로그램, 개발시 주의해야 할 점은 무엇일까?
쓰레드 마다 생성되는 Stack 영역을 제외하면, Heap, Static(Method) 부분은 모든 쓰레드에서 공유하는 자원이다.
추가로 공유 메모리 뿐만 아니라 시스템 자원에서 공유되는 파일을 읽고 쓸수도 있다.
한 프로세스 안에서 공유할 수 있는 메모리, 및 서로 동시에 접근할 수 있는 공유 자원때문에 동기화 문제가 발생한다.
동기화를 위해서는 이 공유되는 영역을 보호해줄 수 있는 장치가 필요하다.
이렇게 멀티쓰레드에 의해서 공유 자원이 참조될 수 있는 코드의 범위를 임계영역이라고 한다.
멀티 쓰레드 프로그램에서 임계영역을 처리하는 경우 의도한 대로 코드가 동작하지 않을 수도 있다. 아주 유명한 예로 은행에 돈 맡기고 돈 인출하는 예제. 이러한 상황을 해결할 수 있는 방법이 동기화를 이용하는 것이다.
동기화는 그럼 어떻게 하는 거지?
동기화는 한정된 자원을 두고 작업들 간의 작업순서를 정하는 것이다.
작업순서를 정할 때는 두 가지 개념을 생각해야 하는데 상호배제(Mutex)와 협동(Cooperation)이다.
화장실을 예로 들어보자, 우리 가족은 4명인데 화장실이 1개가 있다.
내가 볼 일을 보고 있는데 엄마가 들어오면 안된다. 이것이 바로 상호배제다.
그리고 화장실을 다 썼으면 기다리고 있는 엄마에게 알려줘야한다. 이게 바로 협동이다.
이렇게 상호배제와 협동이 잘 구현되면 화장실이 1개라도 안정적으로 운영될 수 있다.
자바는 아래 Lock을 이용한 두가지 방법으로 동기화를 지원한다.
Implicit Lock ( synchronized 키워드)
synchronized키워드를 사용한 방식이 Implicit Lock 방식이다. 객체 헤더안 고유의 Mark Word를 이용한 방식.
Mark Word는 경쟁 정도에 따라 Baised -> Light Weight Lock -> Heavy Weight Lock 으로 상태를 바꾼다.
Heavy Weight Lock 상태부터 Monitor 방식이 구체적으로 사용된다.
Explicit Lock ( java.util.concurrent.locks)
Lock 인터페이스는 원자성있는 lock의 획득과 해제를 담당한다. Condition은 조건별 Wait Set을 만드는다. 사용된다.
이는 Mesa와 Hoare 두 가지 모니터 방식이 혼합된 것이라고 보면된다.
synchronized
자바에서 모든 객체는 내부적으로 모니터를 가지고 있고, 이 모니터를 통해 동기화를 진행한다.
이 모니터를 컨트롤 하는 메소드가 wait(), notifyAll(), notify() 다.
자바에서는 기본적인 상호 배제 방법으로 synchronized 키워드를 사용한다.
메서드나, 해당 구역에 표시해서 사용할 수 있다.
위와 같이 synchronized 키워드가 있으면, JVM은 해당 객체에 Monitor 상호배제 방식을 적용한다.
이를 두고 Implicit Lock이라고 한다. 고유락이라는 의미로 각 객체마다 가지고 있는 객체 헤더의 Mark word를 이용해서 JVM이 상호배제를 하는 방법이다. 해당 방법을 사용하면 JVM이 알아서 상호배제를 해주기 때문에 쓰레드간의 작업순서가 정밀하게 조절될 수가 없다.
한마디로 상호배제는 정확히 일어나는데 상호배제 안에서 필요한 협동이 세밀하게 일어나지 못해서, 특정 쓰레드 들은 전혀 모니터에 접근하지 못하는 공평성의 문제가 생기기도 한다.
그래서 자바에서는 java.util.concurrent.locks 클래스를 제공하여 JVM이 아닌 개발자가 많은 부분을 조작할 수 있도록 Locking 방식을 제공하는데 이를 Explict Lock 이라고 한다.
위에서 언급했다시피 자바의 각 객체들 또한 내부적으로 헤더를 가지고 있고 헤더의 Mark Word는 아래처럼 구성되어 있다.
동기화에서는 해당 Lock 정보를 담고 있는 부분이 사용된다.
쓰레드가 객체에 접근했을 때 객체가 잠겨있는지 열려있는지 확인하는 데 해당 객체가 잠겨있으면 이미 다른 쓰레드가 해당 객체의 메소드를 사용하고 있어 진입을 막아놓은 것으로 보면된다.
Biased Lock
대부분의 경우 경쟁이 없는 경우가 많다. 그 중에서도 동기화된 객체에 한가지 쓰레드만 반복하여 접근하는 경우가 많다. 그러므로 똑같은 쓰레드에 대한 동기화 작업의 반복은 CPU 낭비가 된다. 그래서 Mark Word는 해당 쓰레도 전용 Mark Word로 바뀌는데 이를 두고 Biased Lock 이라고 한다. Biased 비트는 1로 바뀌어 해당 Mark Word는 경쟁상태가 거의 없는 객체임을 나타대고 Hash Code는 쓰레드의 ID로 바뀐다. 그러드로 해당 쓰레드가 Lock을 얻기 위해 재접근을 했을 떄 해당 쓰레드가 저장된 Thread와 일치하다면 별 다른 동기화 검사 없이 손쉽게 Lock을 획득할 수 있다.
Light Weight Lock
하지만 만약 해당 쓰레드가 아닌 다른 쓰레드가 접근을 시도한다면 Baised Lock 상태는 깨져버린다.
이런 경우 Mark Word의 상태는 Light Weight 상태로 전환된다. 이것은 나 혼자 화장실을 사용하고 있었는데
동생이 문 앞에서 언제 나오냐고 닥달하고 있는 상황이다. 즉 공유객체를 향한 경쟁이 증가한 상황이며, 이때 Light Weight 상태로 업그레이드 된다.
이때 객체 고유 정보를 담은 Mark Word는 사라지고 Lock Record 라는 곳의 주소가 공간을 차지하게된다.
그럼 Lock Record는 어디일까? 객체의 Lock을 획득한 쓰레드는 자신의 Stack 메모리에 고유정보가 담긴 Mark Word를 복사한다. 이를 복사한 후 Mark Word의 주인의 주소도 저장시켜 놓는다. 이렇게 생성된 Lock Record가 주인의 주소를 통해 객체를 가리키고 객체의 Mark Word가 Lock Record가 저장된 쓰레드의 Stack을 가리키면 해당 쓰레드는 Lock을 확보한 것이다.
Spin Lock
위의 Light Weight 상태에서 Lock을 확보하지 못한 쓰레드가 대기하면서 계속해서 물어보는 것을 뜻한다.
즉.. 내 화장실 앞에서 동생이 자꾸 언제 나오냐고 물어보는 것이다.. 나올때까지ㅋㅋㅋㅋㅋ!!
내가 나올 때까지 계속해서 물어보는건 비효율 적인 행동이다. 하지만 왜 이렇게 하냐.
쓰레드가 wait 상태로 들어가면 Lock이 풀렸을 때 다시 CPU를 할당받아야 하고 그럼 Context Switching 이라는 오버헤드가 발생된다. 그러므로 무한루프를 도는 것이다. 스핀 락을 도는 쓰레드가 적고 코어의 개수가 많다면 컨텍스트 스위칭보다는 훨씬 이득이 될 수 있다.
이렇게 대기하던 쓰레드는 공유 객체가 unlock 상태가 된 것을 확인하면, 공유 객체의 복사 과정을 거쳐 Lock을 확보한다.
이 과정에서 CAS operation이 작동한다.
즉, 쉽게 설명하면 동생이 내가 화장실에서 나올 때까지 방에 다시 들어갔다가 나오는 행동을 하는 것보다 문 앞에서 기다릴 때가 더 효율적일 수 있다는 것!!!
CAS operation
Compare and Swap Operation은 하드웨어 적인 상호배제를 보장한다.
이게 무슨 의미냐? 우리는 상식적으로 동생이 계속 언제 나오냐고 기다리고 있었기 때문에 다음 차례로 당연히 동생이 화장실에 들어갈 것이라고 생각하지만 특정 상황에 따라 해당 권한이 뺏길수도 있다는 것이다. 가령 문이 열리는 순간 아빠가 화장실에 들어가 버린 꼴이다.
이는 컴퓨터가 로우레벨 어셈블리어로 갈수록 다양한 명령어로 이루어진 복잡한 과정이고, lock을 확보하는 과정에서 다른 쓰레드가 비집고 들어갈 틈이 많다는 뜻이다.
그러므로!! 멀티스레등에서 가장 중요한 것은 Lock을 확보하는 과정에서 원자성을 보장하는 것이다.
이때 원자성을 보장해주는 시스템이 CAS Operation이다.
이런 원자성을 보장해주는 타입을 자바에서 atomic 클래스로 제공하고 있다.
그렇지만 여기서 또 쓰레드 간에 경쟁이 심화되면 어떻게 될까?
Heavy Weight Lock
이때는 모니터 방식으로 상호배제를 구현한다.
공유 객체에 접근한 쓰레드는 Mark Wokd를 통해 현재 Monitor 방식의 상호배제가 이루어지고 있음을 확인한다.
그 후, Monitor Address를 통해 모니터에 접근한다.
자바에서 synchronized 키워드가 사용된 객체는 공유 객체가 된다. JVM은 해당 객체를 토대로 Monitor를 만든다. 공유자원은 동기화된 메소드가 접근하는 공유 객체의 필드가 된다. 그리고 동기화된 메소드 들은 프로시저가 된다.
쓰레드가 모니터에 접근하면, 쓰레드는 공유자원에 직접 접근할 수 없다. 무조건 프로시저를 통해 접근해야한다.
예를 들어 입금()이라는 메서드를 통해 계좌 잔액이라는 공유 객체에 접근하려고 할 때, 쓰레드 하나가 프로시저 입금() 에 접근하면 해당 입금() 메서드는 쓰레드 대신 계좌 잔액이라는 공유 객체에 접근한다. 모니터에는 한 쓰레드만 접근 가능하므로 쓰레드가 출금() 프로시저를 사용하지 않아도 다른 쓰레드는 해당 프로시저에 접근할 수 없다.
이처럼, JVM은 monitor를 추상 데이터 타입(ADT)으로 정의해놓고, Monitor가 필요한 공유 객체를 위해 해당 공유객체 전용 모니터를 만든다.이렇게 자바에서는 간단하게 동기화 작업을 할 수 있다.
세마포어의 경우 세마포어 객체를 생성해주고, acquire()와 release() 메소드를 임계영역 전후로 동기화 메소드마다 위치시켜야 하는 수고가 있다. 하지만, 자바에서는 동기화가 필요한 영역에 syncronized 키워드만 넣어주면 그 뒷일은 알아서 JVM이 처리해준다. 그러므로 개발자가 코드상에 실수할 우려가 줄어드니 동기화 효율이 증가하게 된다.
즉, Monitor는 한번에 하나의 쓰레드만 접근 가능하고 공유자원의 접근은 프로시저가 대신 접근한다.
그럼 지금까지는 상호배제에 대해 알아봤다면,
동기화 작업을 위한 협동에 대해서 알아보자.
동기화는 앞서 말했다시피 작업 순서를 정하는 것이다. 그 안에서 협동이란 작업을 끝낸 쓰레드가 다른 쓰레드 에게 나 작업 끝났어! 라고 알려주는 것이다. 이렇게 쓰레드 간의 소통을 만들어 동기화 작업을 원활하게 만드는 것이 협동이다.
그럼 협동하기전 쓰레드가 가질 수 있는 상태가 무엇인지 알아보자.
쓰레드는 4가지 상태가 있다.
객체 생성 : NEW
실행 대기 : RUNNABLE
일시 정지 : WAITING, TIMED_WAITING, BLOCKED
종료 : TERMINATED
작업순서를 정하는 구체적인 방법으로 Thread 클래스의 yield()와 join()이 있다.
yield()
실행중인 쓰레드를 RUNNABLE(실행대기) 상태로 바꾸는 메소드다.
현재 대기 중인 동등한 우선순위 이상의 다른 쓰레드에게 실행기회를 제공하는 것이다.
join()
다른 쓰레드가 종료될 때까지 일시정지 되었다가 종료가 되면 다시 실행되도록 명령하는 메소드다.
간단히 말해서 A가 실행되다가 B.join()을 만나면 B 쓰레드가 종료될 때까지 A 쓰레드는 그 자리에서 일시정지(WAITING)된다.
그리고, B쓰레드가 종료된 후 A 쓰레드가 다시 실행되는 방식이다.
자주 비교되는 Mutex vs 세마포어
세마포어와 뮤텍스는 모두 동기화를 이용되는 도구이지만 차이가 있다. 자세한 내용은 아래와 같다.
뮤텍스는 Locking 메커니즘으로 락을 걸은 쓰레드만이 임계 영역을 나갈때 락을 해제할 수 있다. 하지만 세마포어는 Signaling 메커니즘으로 락을 걸지 않은 쓰레드도 signal을 사용해 락을 해제할 수 있다. 세마포어의 카운트를 1로 설정하면 뮤텍스처럼 활용할 수 있다.
- Mutex는 동기화 대상이 오직 1개일 때 사용하며, Semaphore는 동기화 대상이 1개 이상일 때 사용한다.
- Mutex는 자원을 소유할 수 있고, 책임을 가지는 반면 Semaphore는 자원 소유가 불가하다.
- Mutex는 상태가 0, 1 뿐이므로 Lock을 가질 수 있고, 소유하고 있는 스레드만이 이 Mutex를 해제할 수 있다. 반면 Semaphore는 Semaphore를 소유하지 않는 스레드가 Semaphore를 해제할 수 있다.
- Semaphore는 시스템 범위에 걸쳐 있고, 파일 시스템 상의 파일로 존재한다. 반면, Mutex는 프로세스의 범위를 가지며 프로세스 종료될 때 자동으로 Clean up 된다.
참고)
https://www.geeksforgeeks.org/thread-scheduling/
https://www.geeksforgeeks.org/inter-thread-communication-java/
https://en.wikipedia.org/wiki/Thread_pool
https://quasarzone.com/bbs/qf_cmr/views/12864
https://lordofkangs.tistory.com/26?category=868253
https://dshuplyakov.github.io/object-header/
https://ionutbalosin.com/2018/06/contended-locks-explained-a-performance-approach/
'Backend > Java' 카테고리의 다른 글
Mac Java 설치 및 버전 여러개 관리 deprecated adoptopenjdk (0) | 2023.01.10 |
---|---|
[Java8to16] 자바 람다 표현식 Lambda Expression (0) | 2022.06.03 |
RESTFul 하다는 건 뭘까? (0) | 2022.04.05 |
전략 패턴(Strategy Pattern) 예제 코드로 이해하기 (0) | 2022.04.04 |
파사드 패턴(Facade Pattern) 예제 코드로 이해하기 (0) | 2022.04.03 |