티스토리 뷰

👾 아래 문서 정리 ⬇️

https://engineering.universe.com/introduction-to-concurrency-models-with-ruby-part-i-550d0dbb970

 

Introduction to Concurrency Models with Ruby. Part I

In this first post, I would like to describe the differences between Processes, Threads, what the GIL is, EventMachine and Fibers in Ruby.

engineering.universe.com

 

 

🐶 프로세스

concurrency => 한사람이 한손으로만 여러개의 공을 저글링하고 있는 것. 어떻게 보이든지 간에 이 사람은 한번에 한 공만 던지거나/받고 있다.

parallelism => 여러 사람들이 동시에 각자의 공들을 저글링하고 있는 것.

 

순차적 실행

# sequential.rb
range = 0...10_000_000
number = 8_888_888
puts range.to_a.index(number)  #=> 해당 array에서 number의 인덱스를 리턴

이 코드를 실행하는데 대략 500ms 소요, utilize 1 cpu 

 

*(참고) linux time command

-> used to execute a command and prints a summary of real-time, user CPU time and system CPU time

 

병렬 실행

프로세스 fork떠서 child process 2개에서 각자 코드 블럭 실행. parent process는 Process.wait으로 자식 프로세스들 모두 종료될때까지 기다리기.

# parallel.rb
range1 = 0...5_000_000
range2 = 5_000_000...10_000_000
number = 8_888_888
puts "Parent #{Process.pid}"
fork { puts "Child1 #{Process.pid}: #{range1.to_a.index(number)}" }
fork { puts "Child2 #{Process.pid}: #{range2.to_a.index(number)}" }
Process.wait

실행시간 단축, 1개 보다 더많은 cpu utilization.

 

장점

- 프로세스는 메모리를 공유하지 않기 때문에, 한 프로세스에서 다른 프로세스 데이터를 변경할 수 없다. 이때문에 코드 작성, 디버깅이 훨씬 쉬움. 

- GIL(global interpreter lock) 때문에 Ruby MRI(= CRuby)에서는 single-core 보다 많이 utilize하는 유일한 방법은 프로세스이다.

- child 프로세스를 활용하는 것은 원치 않는 메모리 누수를 피하는 방법이다. 프로세스 종료하면 할당받은 자원을 해제하기 때문에.

 

단점

- 프로세스끼리 메모리 공유를 하지 않기 때문에, 멀티프로세싱은 메모리 소모량이 많다. (수백개의 프로세스를 실행하는 것은 문제가 될 수 있다...) 다만 Ruby2.0 부터는 fork메소드가 COW(Copy-on-Write)를 사용하기 때문에 value modification이 있기 전까지는 프로세스 간의 memory share 가능. (origin, copy본이 같은 곳을 참조)

- 프로세스는 생성과 삭제가 느리다

- 프로세스 사용시 IPC(Inter Process Communication)가 필요할 수도 있다. (DRb - distributed object system for ruby, druby 처럼)

- orphan 프로세스 ( parent 프로세스가 종료된 프로세스)나 zombie 프로세스 (종료되었는데도 process table에서 계속 공간을 차지하는 프로세스)를 조심해야 한다.

 

예시

- Unicorn 서버 => 여러 Http request를 받기 위해 마스터 프로세스를 여러개의 워크 프로세스로 spawn한다.

- Resque => 백그라운드 프로세싱 (레일즈 Active Job이 adapter를 제공하는 queueing backend로는 sidekiq, resque, delayed-job 등등이 있음..)

 

 

 

 

 

🐶 스레드

루비 1.9부터는 native OS스레드 (커널 스레드)를 사용한다고 하더라도, 하나의 프로세스에서 한번에 (시간적으로) 오직 한 스레드만이 실행될 수 있다. (코어가 남아돌아도). 이것은 Ruby MRI의 GIL때문이다. (Python같은 다른 인터프리터 언어에도 존재함)

 

GIL이 존재하는 이유는?

- non-thread safe한 C 라이브러리 위에서 동작할때, race condition을 피하기 위해. (The official Ruby interpreter is written in C)

- 루비 데이터 구조를 thread-safe하게 변경할 필요가 없어서 구현이 쉬워지기 때문에.

 

Ruby MRI = Matz's Ruby Interpreter = 마츠모토 유키히로 이양반이 만든 original ruby..? JVM 위에서 동작하는 JRuby와 구분됨.

 2014년부터 마츠모토씨는 점진적으로 GIL을 제거하기로 마음먹음. 왜냐면 GIL이 실제로 완전한 thread-safe함이나 더 나은 동시성 구현을 보장하지 않기 때문에.

 

race-condition

# threads.rb
@executed = false
def ensure_executed
  unless @executed
    puts "executing!"
    @executed = true
  end
end
threads = 10.times.map { Thread.new { ensure_executed } }
threads.each(&:join)



코드 작성의도는 한번만 executing 되는 것이었겠지만 @executed read, write이 atomic하지 않기 때문에 의도와 다르게 2번이나 executing! 됐다.

(첫번째 unless 블럭 안으로 들어온 스레드가 @executed = true로 세팅하기 이전에 다른 스레드로 unless 블럭 안으로 들어와 버려서..)

 

GIL과 blocking I/O

여러 스레드가 동시에 실행되는 것을 허용하지 않는 GIL이 있다고 해서 스레드가 유용하지 않은 것은 아니다.

왜나면 스레드가 blocking되는 I/O작업을 수행할때는 GIL을 release 하기 때문에 (다른 스레드가 실행될 수 있도록)

(예를 들어 Http request, DB 쿼리 날리기, 디스크에서 read, write, 심지어 sleep도)

# sleep.rb
threads = 10.times.map do |i|
    Thread.new { sleep 1 }
end
threads.each(&:join)

10개 스레드가 각자 1초씩 sleep 했는데 모든 스레드가 1초뒤 거의 동시에 끝남.

한 스레드에서 sleep 실행하면 GIL을 release해서 그동안 다른 스레드가 실행될 수 있도록 한다.

 

장점

- 프로세스보다 메모리를 덜 사용한다. 수천개의 스레드를 돌리는게 가능함. 그리고 생성, 삭제도 훨씬 빠르다

- 느린 blocking I/O 작업을 해야할때 스레드는 유용하다.

- 다른 스레드에서 shared 메모리 영역을 접근하는 것이 가능하다. (장점이자 단점이 될 수 있음)

 

단점

- race-condition을 피하기 위해서 아주 섬세한 synchronization이 필요하다. 원시적으로는 락을 사용하는데, 이건 데드락을 발생시킬 수도 있다. 이 모든 것들이 thread-safe한 코드를 작성하고 테스트하고 디버깅하기 어렵게 만든다.

- 스레드를 사용하려면 당신이 직접 적은 코드 뿐만아니라 사용하는 라이브러리등 dependency들 역시 thread-safe함을 확인해야함

- 스레드 spawning을 많이 할 수록 실제 코드 execution 보다 컨텍스트 스위칭 같은 데에 시간과 리소스를 사용하는 오버헤드가 커짐.

 

예시

- Puma 서버 (현재 rails 디폴트 서버) => 각 프로세스에서 여러개의 스레드를 사용하는 것을 가능하게 함. Unicorn과 비슷하게 cluster 모드로 마스터 프로세스를 여러개의 child 프로세스로 spawning 한다. 각 child 프로세스는 각자의 스레드 풀 가지고 있음.

GIL때문에 각 프로세스는 최대 1개 이상의 코어를 utilize할 수 없지만 스레드마다 http request 1개를 전담해 처리하면서, blocking I/O 발생하면 GIL Release해서 그동안 다른 스레드가 다른 http request의 작업을 처리할 수 있도록 함.

On MRI, there is a Global VM Lock (GVL) that ensures only one thread can run Ruby code at a time. But if you're doing a lot of blocking IO (such as HTTP calls to external APIs like Twitter), Puma still improves MRI's throughput by allowing IO waiting to be done in parallel.

- Sidekiq => 백그라운드 프로세싱. runs a single process with 25 threads by default. 각 스레드는 한번에 1개의 job을 처리한다 ( puma 서버에서 각 스레드가 한번에 1개의 http request를 처리하는 것처럼..?)

 

 

 

 

 

 

 

🐶 Event machine

c++과 루비로 쓰여진 gem이다. reactor패턴을 사용해서 event-driven I/O를 제공한다. 루비로 node.js 같은 코드 작성 가능! (event-loop). EM은 내부적으로 event loop을 도는 중에 새로운 file descriptor에서의 input을 체크하기 위해 리눅스 select()를 사용한다.

보통 Event Machine을 사용하는 경우는 수행할 I/O 작업이 많고, 이 스레드들을 모두 수동으로 처리하고 싶지 않을 때이다.

스레드를 수동으로 핸들링하는 것은 어렵고, 자원 사용 관점에서 비쌀 수 있다.

EM을 사용하면 싱글 스레드 (default)로 다수의 http request를 처리할 수 있다 (=> 이거 완전 노드 쟈나!)

2021.11.29 - [이론/동시성모델] - 서버는 여러개의 request를 어떻게 감당할까?

 

서버는 여러개의 request를 어떻게 감당할까?

일반적으로 웹 애플리케이션의 쓰레드 1개가 요청을 받았을 때 동작 방식은 위와 같다. 요청이 들어온 때부터 응답을 리턴해줄 때까지 하나의 스레드는 해당 요청의 작업만을 전담하여 수행한

pinball1973.tistory.com

# em.rb
require 'eventmachine'

EM.run do
    EM.add_timer(1) do
      puts 'sleeping...'
      EM.system('sleep 1') { puts "woke up!" }
      puts 'continuing...'
    end
    EM.add_timer(3) { EM.stop }
end

EM.run - EM.stop 계속 event-loop 돈다

EM.add_timer (n) { 콜백 } => n초 뒤 콜백함수 실행. node setTimeout 같이 동작

EM.system( 시스템 명령어 ) { 콜백 } => 시스템 명령어 실행한 뒤 콜백 실행.

 

* (참고) multiplexing I/O

linux - select, poll, epoll

FreeBSD - kqueue

window - IOCP

 

장점

- 웹서버나 프록시 같은 느린 networked app에서 싱글 스레드로 더 좋은 성능을 낼 수 있게 해준다.

- 복잡한 멀티스레드 프로그램을 작성해야 하는 것을 피할 수 있게 해준다.

 

단점

- 모든 I/O 작업이 EM 비동기를 지원해야 한다. 고로 이를 지원하는 특정 버전의 system, DB 어댑터, http 클라이언트 등을 사용해야 함을 의미하고 (예를들어 response가 올때까지 current 스레드를 blocking하는 faraday http request는 여기에 적합하지 않음)

이는 몽키패치로 이어질 수 있다.

- event loop tick 당 메인 싱글 스레드에서 수행하는 작업 규모는 작아야 한다. Defer를 사용하는 것도 가능. (메인 스레드를 blocking할 만한 코드는 스레드 풀 내의 별개의 스레드에서 진행하기). 하지만 이것은 또다시 위에서 설명한 멀티스레드 문제점을 야기할 수 있음.

- 에러 핸들링, 콜백 때문에 복잡한 프로그래밍을 하기는 어렵다. 콜백 헬이 루비에서도 발생할 수 있지만 아래에서 설명할 Fiber로 해결할 수 있다.

- Eventmachine 자체가 사이즈가 큰 dependency (라이브러리..?)이다. 루비로 17k줄, c++로 10K줄 자리 코드임.

 

예시

- goliath 서버 => 싱글 스레드 비동기 서버 ( express 같이 동작하는거겠지 뭐..)

- amqp => rabbitMQ 클라이언트 gem. gem 개발자는 non-EM-based 버전인 bunny 사용을 더 권고하고 있다. 

(* EM-less 구현으로 교체하는게 요즘 트렌드다. 예를 들어 ActionCable => 더 low-level인 nio4r를 사용하기로 결정함. sinatra-syncrhony => celluloid 사용해서 다시 작성함)

 

 

 

 

 

 

 

 

🐶 Fiber

루비 standard library에서 제공하는 경량 primitive. 수동으로 pause, resume하는 스케줄링 할 수 있다. 자바스크립트 ES6 제너레이터랑 비슷한거다. 싱글 스레드에서 수만개의 Fiber를 돌리는게 가능하다.

2022.01.23 - [이론/동시성모델] - yield에 관한 고찰

 

yield에 관한 고찰

먼저 시작하기 전에 다음의 글(Implementing threads)을 배경 지식으로 같이 깔고 가면 좋겠다. yield하면 생산이라는 뜻이 먼저 떠올라서 의미가 잘 안 와닿았는데, 멀티스레딩 관점에서는 양보라는 다

pinball1973.tistory.com

보통 callback hell 피하고 동기적으로 보이는 코드 작성하려고 Eventmachine 내부에서 많이 쓰인다. 

Fiber 없는 버전

EventMachine.run do
    page = EM::HttpRequest.new('https://google.ca/').get       
    page.errback { puts "Google is down" }
    page.callback {
      url = 'https://google.ca/search?q=universe.com'
      about = EM::HttpRequest.new(url).get
      about.errback  { ... }
      about.callback { ... }     
    }
  end

Fiber 도입한 버전

EventMachine.run do
  Fiber.new {
    page = http_get('http://www.google.com/')     
    if page.response_header.status == 200
      about = http_get('https://google.ca/search?q=universe.com') 
      # ... 
    else 
      puts "Google is down"
    end  
  }.resume 
end

def http_get(url)
  current_fiber = Fiber.current
  http = EM::HttpRequest.new(url).get    
  http.callback { current_fiber.resume(http) }   
  http.errback  { current_fiber.resume(http) }    
  Fiber.yield
end

(1) Fiber 내부에서 호출할 http_get 함수 정의하기

=> 현재 Fiber를 변수에 담은 다음, async http req 호출. 해당 작업 성공 및 실패시 콜백으로 이 fiber 재개하는 코드 등록 해놓고 현재 Fiber pause하기.

 

(2) EM 내부에서 Fiber 생성한 뒤 바로 실행.

=> 해당 Fiber는 http_get 메소드 호출 해서 (1)번 내용 수행한뒤 바로 pause 됨. http req 끝나면 거기 등록된 콜백으로 다시 resume 되면서 if ~ 줄부터 실행.

 

장점

- nested callback (콜백 헬)을 없애서 비동기 코드를 더 단순하게 작성할 수 있도록 해준다.

 

단점

- 진짜로 concurrency 문제를 해결해주는 건 아니다.

- 상용 application 레벨 코드에서 직접 사용되는 경우는 드물다.

 

예시

- em-synchrony => mysql2, mongo, memcached 같은 다른 클라이언트를 위해 Eventmachine, Fiber를 통합해 작성한 라이브러리. 

 

 

 

 

 

 

 

🐶 결론

만능 해결책은 존재 하지 않는다. 상황에 맞는 것을 선택해야 한다.

예를 들어 자원이 충분하고, cpu, memory intensive 한 코드를 작성해야 한다면 프로세스를 사용해라.

http request 같은 다수의 I/O 작업을 수행해야 한다면 스레드를 사용해라.

그리고 이 상황에서 throughput을 맥시멈으로 올리고 싶으면 Event machine을 사용해라.

 

 

댓글
공지사항
최근에 올라온 글