티스토리 뷰

일반적으로 웹 애플리케이션의 쓰레드 1개가 요청을 받았을 때 동작 방식은 위와 같다. 

요청이 들어온 때부터 응답을 리턴해줄 때까지 하나의 스레드는 해당 요청의 작업만을 전담하여 수행한다. (blocking)

처리 중간에 데이터베이스 응답을 기다리거나, 다른 서버에서 api 응답을 기다리느라 cpu가 idle 한 상태이더라도 그 사이 다른 request를 처리하지 못한다.

 

 

1. multi-thread approach ( rails puma 서버 외 다수 )

대부분의 서버 어플리케이션은 멀티스레드로 동작하기 때문에,

스레드 1개만 놓고 봤을때는 하나의 요청을 blocking 방식으로 처리함에도 불구하고

전체 서버 app(프로세스) 단위에서는 서로 다른 코드 부분 ( 예를 들어 session_controller#logout 액션과 movie_controller#lists 액션 ) 이 동시에 실행될 수 있는 것이다. 

( 멀티 코어라면 서로 다른 스레드가 진짜로 "parallel"하게 돌아갈 것이고, 싱글 코어라도 스케줄링과 context switching으로 "concurrent"하게 돌아 갈 것.)

 

코드 참고하기 : https://gist.github.com/jjyoummmin/6e7efd5edc19f0c4fad8768862ab3918

 

long 요청을 먼저 보낸 뒤 1초 후short 요청을 보내는 클라이언트 코드를 실행한다고 해보자.

이때 서버의 long은 cpu를 계속 점유하는 긴 연산이 아니라

제3의 api 요청 결과를 기다린다든지, 데이터베이스에 요청을 보내놓고 기다리는 것 같은 해당 스레드가 waiting 상태가 되어 코어에서 다른 스레드가 스케줄링 될 수 있도록 하는 작업이어야 한다.

 

( * 참고 - ruby sleep )

Suspends the current thread for duration seconds

Called without an argument, sleep() will sleep forever.

 

 

스레드에 대한 설정을 default로 하고 puma 서버를 실행했을 때는,

short 요청을 더 늦게 보냈더라도, long 요청을 담당하는 스레드가 waiting 큐 (정확히 딱 "waiting 큐" 인지는 모르겠다. 운영체제 마다 구현이 다르지 않을까?) 에서 대기하는 동안 short 요청을 담당하는 스레드가 돌 수 있기 때문에, 응답 결과는 short 받고 long 이다.

 

그런데 puma 스레드를 1개로 설정하고 서버를 실행하면

요청을 받을 수 있는 스레드가 1개 뿐이라 long 처리 한다음에 short 요청을 처리한다.

 

 

 

2. single thread with Event loop approach ( node.js )

이전 글 참고하기 : 반복문 비동기 처리 ( + Event Loop, Promise )

 

노드 익스프레스 서버에 요청을 보냈을 때도,

이벤트 루프 같은 동작 매커니즘 덕분에 메인스레드가 1개이더라도 푸마 서버가 멀티스레드로 돌때와 같은 방식으로 응답한다.

단, 위에서 설명한대로 long에서의 긴 작업이 background에 위임할 수 있는 setTimeout 같은게 아니라 메인스레드가 계속 일해야 하는 

사용자 정의 sleep같은 코드였다면 싱글스레드 푸마 서버때의 결과와 같이 long -> short 순서로 응답을 받을 것이다.

function sleep(delay) {
    let start = new Date().getTime();
    while(new Date().getTime() < start+delay);
}

 

스레드 생성에 필요한 오버헤드가 없기 때문에 오히려 IO bound한 작업만 놓고 비교하자면 오히려 노드가 더 성능이 좋다고 한다.

멀티스레딩 방식으로는 놀고있는 스레드한테도 메모리 영역 (콜스택, TCB (tid, sp, pc, state, register value, pcb pointer) 같은 것들)

을 할당해줘야 하니까 메모리 제약으로 스레드 카운트를 무한정 늘릴 수는 없다.

 

그렇지만 노드가 싱글 메인스레드 만으로 다른 멀티스레드 웹서버와 비슷한 성능을 낼 수 있는 것은 IO bound한 요청에 한해서이다.

 

db, api 요청보내는 것 처럼 다른데의 일처리 결과를 기다리는게 아니라 cpu bound한 작업을 해야 한다면 프로세스/스레드 형태로 코어에 올려져야 하기 때문이다.

코어 여러개가 남아돌아도 우리가 작성한 서버 코드가 활용하는 코어는 1개 뿐이다. 싱글 스레드니까. ( 당연히 libuv 라이브러리 코드를 호출 했다면 이게 백그라운드 스레드풀을 사용하긴 하겠지만.. )

( 더 엄밀히 비유하자면 스레드는 worker라기 보다는 몇번째 instruction까지 수행한 상태고, 거기까지 했을 때 call stack은 어떻고 하는 작업기록표? 같은 것이겠지만, 그냥 직관적으로 이해하기에는 cpu 작업장에서 서버 코드들이 실행되기 위해 스레드라는 worker를 통해야 한다는 비유도 꽤 괜찮은 것 같다)

 

 

(1) solution1 - worker thread ( 멀티스레딩)

다른 코어가 놀고 있는데 하나뿐인 메인스레드가 cpu bound한 계산을 하느라 다른 요청을 못받고 있다? => 워커 스레드를 생성해서 그런 계산은 다른 코어 가지고 가서 해라. 그동안 메인스레드는 다른 요청 받을게.. 그리고 계산 끝나면 프로미스 콜백으로 후속 처리해서 응답 보내기.

 

노드 내부적으로 libuv라이브러리 등을 통해 백그라운드 스레드를 이용하는 방식 말고도 worker_threads 모듈을 사용해서 개발자가 직접 멀티스레드 코드를 짤 수도 있다.

# cpuConsumingJob 모듈

const { Worker, isMainThread, parentPort } = require('worker_threads')

function sleep(delay) {
    let start = new Date().getTime();
    while(new Date().getTime() < start+delay);
}

if (isMainThread) {
  module.exports = function cpuConsumingJob() {
      return new Promise((resolve, reject) => {
          const worker = new Worker(__filename)
          worker.on('message', message => resolve(message))
          worker.on('error', reject)
          worker.on('exit', (code) => {
              if (code !== 0) {
                  reject(new Error(`Worker stopped with exit code ${code}`))
              }
          })
      })
  }
} else {
  sleep(2000)
  const result = "complex computation result!"
  parentPort.postMessage(result)
}
# 서버 코드
const cpuConsumingJob = require('./cpuConsumingJob')

# 중략

app.get('/long', (req, res) => {
    cpuConsumingJob().then(value=>res.send(value))
})

이런식으로 짜면 메인스레드가 워커스레드를 생성해서 (spawn thread) cpu bound한 계산은 이 워커스레드가 처리하고 그 동안 하나 뿐인 메인 스레드는 다음 short 요청을 받을 수 있다. 

 

 

(2) solution2 - cluster mode ( 멀티프로세싱)

마치 스케일 아웃해서 서버 인스턴스를 여러개 띄우듯이, cpu 코어 개수만큼 서버 프로세스를 fork 해서 멀티프로세스 모드로 운영하는 방식. 라운드 로빈 방식의 로드밸런서를 제공해서 마스터 프로세스에 들어온 요청을 워커 프로세스들로 분배하는 방식으로 동작한다고 한다.

프로세스이기 때문에 서로 메모리를 공유하지 못하므로 그런 기능을 위해서는 memcached나 redis를 도입해야 한다.

그러나 이것은 여러 request를 몇개의 인스턴스가 나눠가지는 효과를 주는 것이지 하나의 request가 엄청 오래걸리는 cpu bound한 작업을 해야한다면 여전히 그 요청을 받은 프로세스의 메인스레드 는 다른 요청을 못받는다.

 

* pm2 패키지를 사용하면 노드 cluster 모듈을 사용하지 않고도 간단하게 클러스터링 모드로 실행할 수 있다.

pm2 start server.js -i 0

이런식으로 pm2 i 옵션 뒤에 생성하기 원하는 프로세스 개수를 기입하면 된다고 한다.

0 => 현재 cpu 코어 개수만큼

-1 => 현재 cpu 코어 개수보다 1개 적게

 

 

 

마치며..

 

처음 이걸 찾아보게 된 계기는 노드에서는 axios 요청을 비동기로 처리했는데, rails 프로젝트 코드에 있는 faraday 요청은 왜 응답을 기다린 후에 다음 줄을 실행하는 blocking 방식으로 짜여있을까 하는 의문이었다.

노드는 싱글스레드 이기 때문에 이 하나뿐인 메인스레드를 blocking 하지 않으려고 (non-blocking) 그렇게 기를 쓰고 모든 코드를 비동기 + 콜백으로 짰던 것이고

puma 서버에서는 어차피 스레드 하나가 지금 담당하고 있는 이 request에 응답을 주기 전에 잠깐 다른 request를 먼저 처리하는 식으로 동작하지 않기 때문에 굳이 faraday요청을 비동기로 보낼 필요가 없었던 것이었다.

 

client.js 파일 대신에 long 응답 받은 후에야 short 요청을 보내는 client.rb 파일을 실행하면 어떤 서버에 요청하든지 간에 무조건 결과가 long -> short 이다.

 

다만, puma 서버의 스레드가 한 request의 처리를 위해 제3의 api 서버에 faraday 요청을 보내놓은 뒤 기다리는 동안 다른 request를 처리하는 일은 일어나지 않지만

만약 한 request의 처리를 위해 다수의 api 요청/응답을 받은 뒤 최종 응답을 돌려줘야하는 상황이라면 코드를 async하게 짜는 것도 고려해볼 법 한 것 같다.

 

 

참고

스택오버플로우 질문 - How, in general, does Node.js handle 10,000 concurrent requests?

A simple guide to Javascript concurrency in Node.js and a few traps that come with it

'시리즈 > Concurrency' 카테고리의 다른 글

메모리로 보는 프로세스와 스레드  (0) 2022.01.23
IPC, 프로세스간 통신 그리고 gRPC  (0) 2021.12.05
EDA 메세징 시스템 - 큐 vs 로그  (0) 2021.11.04
RabbitMQ 왕기초  (0) 2021.10.28
동시성 재료 살펴보기  (0) 2021.10.27
댓글
공지사항
최근에 올라온 글