티스토리 뷰
👾아래 문서 정리 ⬇️
https://engineering.universe.com/introduction-to-concurrency-models-with-ruby-part-ii-c39c7e612bed
Introduction to Concurrency Models with Ruby. Part II
In the second part of our series we will take a look at more advanced concurrency models such as Actors, CSP, STM and of course Guilds
engineering.universe.com
파트2에서는 Actor, CSP, STM, Guild 같은 심화된 동시성 모델을 살펴볼 것이다. 야호 😎💃
🐶 Actor
Actor operation
- 다른 액터 생성하기
- 메세지 전송하기
- 다음 메세지를 어떻게 핸들링할지 지정하기
액터는 위와 같은 작업을 수행한다. 각자 private state를 저장하고 있고 서로 공유하지 않음. 따라서 actor 끼리는 공유 메모리 같은 것 없이 오직 메세지를 통해서만 통신함. 공유 상태 같은 것이 없기 때문에 락을 사용할 필요도 없다.
Do not communicate by sharing memory; instead, share memory by communicating.
다른 language 유명한 구현체
=> Erlang, Elixir, Akka 라이브러리 (JVM위에서 도는 application을 위한 concurrency 툴킷. java, Scala)
루비에서는 Celluloid가 가장 유명한 구현체이다.
기저 작동 방식을 보면, 각 Actor가 서로 다른 스레드 위에서 돌아가고, 다른 Actor의 응답을 기다리는 동안 메소드가 blocking되는 것을 막기 위해 모든 메소드 호출에 Fiber를 사용한다.
( 추가 설명 하자면 )
클래스 안에 include Celluloid를 넣어 정의하면 이 클래스의 인스턴스가 액터가 된다.
new 할 때 새로운 native 스레드가 spawn 되어 이 액터의 메소드 코드는 이 새로운 커널 스레드에서 실행된다, 다른 일반 루비 객체처럼 자동으로 garbage collecting 해주지 않기 때문에 생명을 다한 액터라면 명시적으로 terminate 해줘야 한다.
*각 액터마다 separate 스레드에서 동작하게 설계되어있는데, celluloid를 ruby MRI에서 사용하면 GIL때문에 성능을 못 뽑아내고. (1코어 utilize 밖에 못함..) GIL 없는 RVM 위에서 도는 루비 (JRuby, Rubinius)를 사용할 것을 권고하고 있는 것 같다..
Celluloid experiment with MRI / JRuby
액터 모델에서는 서로 msg sending을 통해서만 소통하는데, celluloid에서는 함수 호출이 msg send하는 액션에 해당함.
( 루비 문법중에 send 구문으로 함수 호출하는 것도 있으니 그거랑 연관지을 수도 있으려나..?)
일반 호출은 resonse가 있을때까지 sender (=function caller)를 blocking하고, async 키워드를 붙여서 호출하거나 bang(!) 호출하면 sender는 blocking 되지 않고 바로 다음 줄 코드 진행한다.
async 호출 시 return value로 뭔갈 하고 싶을 땐 future를 사용할 것. (=> 자바스크립트 프로미스 같은거)
또 여러가지 기능들을 제공하는데 이를테면,
Celluloid::Actor[:world] = World.new이런식으로 Celluloid repository에서 이름으로 해당 액터에 접근할 수 있게 등록해 둘 수 있음.
그리고 supervisor 기능을 사용하면 celluloid가 알아서 celluloid repository에 등록한다음에 exception 발생등 액터에서 실패가 발생하면 retrial 하도록 관리해줌.
Pool은 코어 개수만큼 해당 클래스의 액터 객체 생성한다음에 함수 호출 있으면 이 풀에서 적당한 액터 배정해주는 방식.
link로 서로 다른 액터끼리 연결해서 crash notification을 줄 수도 있다.
자세한 설명은 다음 링크를 참조하시길. (나도 슬쩍만 봤다)
https://github.com/celluloid/celluloid/wiki
An Introduction to Celluloid, Part I
An Introduction to Celluloid, Part II
An Introduction to Celluloid, Part III
# actors.rb
require 'celluloid'
class Universe
include Celluloid
def say(msg)
puts msg
Celluloid::Actor[:world].say("#{msg} World!")
end
end
class World
include Celluloid
def say(msg)
puts msg
end
end
Celluloid::Actor[:world] = World.new
Universe.new.say("Hello")
장점
- 개발자가 직접 멀티 스레드 프로그래밍을 할 필요가 없다. 액터간 공유하는 메모리가 없다는 것은 명시적인 락 없이도 deadlock-free 동기화가 가능하다는 것을 의미한다.
- Erlang과 비슷하게, Celluloid는 액터를 Fault-tolerant하게 구현했다. Supervisor를 사용해서 박살난 Actor를 재부팅 시키려 할 것이다.
- 액터는 분산 문제를 해결하기 위해 디자인 되었다. 여러대의 머신으로 스케일링 할 때 좋은 선택지이다.
단점
- 시스템에서 공유 상태를 써야하거나, 특정 순서대로 동작함을 보장해야 할 경우에는 액터를 사용하는 것이 맞지 않는다.
- 디버깅이 까다롭다. 수많은 액터 사이에서 코드 flow를 파악하기, 또 특정 액터가 message를 변경할 수도 있음.
- 스레드를 직접 다루는 것 보다 celluloid로 복잡한 동시성 시스템을 더 쉽고 빠르게 구축할 수 있음. 하지만 runtime cost는 훨씬 크다. (5배 더 느리고 8배 메모리를 더 소요)
- 불행히도, 루비 액터 구현체는 여러 대의 분산 서버에서 운영하기에 그렇게 효율적이지 못하다. 예를들어 0MQ를 사용하는 Dcell은 아직 production 환경에서 사용하기 불충분함.
예시
- Reel => event-based 웹서버. celluloid-based로 작성한 어플리케이션을 돌리는데 좋다. connection 1개당 액터 1개. 스트리밍이나 웹소켓에 사용될 수 있음
- Celluloid::IO => 액터 + evented I/O loop. Eventmachine과 다르게 여러개의 액터를 생성하는 것으로, 원하는 개수 만큼의 event-loop를 사용할 수 있다. (한 프로세스 당)
🐶 CSP (Communicating Sequential Processes)
Actor모델과 굉장히 비슷한 패러다임이다. 마찬가지로 공유 메모리 없이 message passing을 사용하는 방식이다.
하지만 Actor 모델과 CSP는 2가지 중요한 차이점이 있다.
- CSP의 프로세스는 익명이다. Actor는 identity가 있음. (Celluloid예시에서도 봤듯이 Celluloid repository에서 이름으로 참조해서 함수 호출할 수 있음). 따라서 CSP는 메세지 패싱을 위해 명시적인 channel을 사용한다! Actor에서는 채널 같은거 없이 직접 메세지 전달.
- CSP에서는 receiver가 받을 준비가 됐을때까지 sender가 메세지를 부치지 못한다. 액터는 비동기적으로 메세지 전송 가능. (Celluloid async 호출 처럼)
Golang: channels implementation
다른 language 유명한 구현체
=> Go 의 goroutine = 경량스레드 and channels, Clojure의 async.core 라이브러리, Crystal( Ruby에서 영감받은 언어인 듯?) 의 fibers and channels.
루비에서는 CSP를 구현한 몇가지 gem들이 있다. 그 중 하나가 concurrent-ruby(루비 concurrency 툴 라이브러리)에 구현되어 있는 Channel 클래스가 있다.
# csp.rb
require 'concurrent-edge'
array = [1, 2, 3, 4, 5]
channel = Concurrent::Channel.new
Concurrent::Channel.go do
puts "Go 1 thread: #{Thread.current.object_id}"
channel.put(array[0..2].sum) # Enumerable#sum from Ruby 2.4
end
Concurrent::Channel.go do
puts "Go 2 thread: #{Thread.current.object_id}"
channel.put(array[2..4].sum)
end
puts "Main thread: #{Thread.current.object_id}"
puts channel.take + channel.take
Concurrent::Channel.go에 주어진 block 코드는 다른 스레드에서 실행 된다. 메인 스레드에서 결과 동기화하고 토탈 value 계산.
모든 것은 명시적인 락 없이 channeld을 통해 이루어진다.
각 Channel.go가 스레드 풀에 있는 각기 다른 스레드에서 실행 된다. 이 풀은 더이상 남은 free 스레드가 없으면 자동으로 개수 늘림.
장점
- CSP 채널은 최대 1개 메세지만 들고 있을 수 있어서 코드 분석하기가 쉬워진다. (액터 모델에서는 무한개의 mailbox와 message를 가질 수 있는 가능성이 있음)
- CSP는 channel을 사용함으로써 producer, consumer를 decoupling할 수 있게 해줌. 서로에 대해 알필요가 없음.
- 메세지가 순서대로 전달된다.
단점
- CSP는 일반적으로 1대의 머신에서만 사용됨. 분산 프로그래밍에서는 Actor보다 안 좋음.
- 루비에서는 대부분의 구현체가 M:N 스레딩 모델을 사용하지 않음. ( 루비 1.9 부터 Thread는 native 스레드 활성화함)
따라서 Ruby 스레드를 사용하는 "goroutine"은 실질적으로 커널 스레드이다. ( user level thread인 Golang의 고루틴 처럼 경량이 아님)
- 루비에서 CSP를 사용하는 방식은 유명하지 않음. 그래서 안정화 되지도 않음.
예시
- Agent => 다른 루비 CSP 구현체. 이 gem 역시 각 go 블럭을 별개의 루비 스레드에서 실행한다.
🌻Actor vs CSP 간단정리
참고 문서 => Parallelism models. Actors vs CSP vs Multlithreading
Actor | CSP | |
공통점 | message passing 방식 ( no shared memory ) | |
차이점 | message 전송 비동기 | channel reader가 읽을 때 까지 channel writer block 된다. channel 은 msg 1개만 들고 있을 수 있음. |
각 Actor 서로 식별 가능. address system. mailbox (= 메세지 큐). 채널 같은거 없이 메세지 직접 전달. (directly) |
프로세스는 channel (=일급 객체) 을 사용해서 통신한다. | |
Best effort, at-most-once delivery. 순서 장담 못함 |
메세지 순서대로 전달됨. | |
여러대 머신에 스케일아웃 해서 적용하기 좋음. 분산시스템에 최적. | single machine에 가장 fit 하다 |
🐶 STM (Software Transactional Memory)
액터와 CSP가 message passing을 기반으로 하는 동시성 모델이었다면, STM은 공유 메모리를 사용하는 모델이다.
락 기반 동기화의 대안으로 사용할 수 있다. DB 트랜잭션과 비슷하게 아래와 같은 것들이 주요 개념이다.
- 트랜잭션 안에서 값이 바뀔 순 있지만, 트랜잭션이 커밋되기 전까지는 변화가 밖에선 안보임
- 트랜잭션 안에서의 에러는 모든 변화를 롤백하고 이전으로 돌아가게함.
- 변경사항 충돌때문에 트랜잭션 커밋이 안되면 성공할 때까지 재시도 한다.
Clojure => core language 레벨에서 STM 지원함
루비 구현체로는 Concurrent::Tvar 클래스가 있다. (ractor-tvar for Ruby3.0 도 있음)
# stm.rb
require 'concurrent'
account1 = Concurrent::TVar.new(100)
account2 = Concurrent::TVar.new(100)
Concurrent::atomically do
account1.value -= 10
account2.value += 10
end
puts "Account1: #{account1.value}, Account2: #{account2.value}"
Tvar (transactional variable)는 single value를 들고있는 객체다. atomically와 함께 써서 트랜잭션 안에서의 데이터 변경을 구현하는데 사용됨.
장점
- 락 기반 프로그래밍 보다 STM을 사용하는 것이 훨씬 쉽다. 데드락을 피할 수 있게 히주고 race condition을 고려하지 않아도 되서 코드 파악하는게 훨신 쉬워짐.
- 코드 구조를 완전 다시 짜야하는 액터나, CSP랑 다르게 적용하기 훨씬 쉽다.
단점
- STM이 트랜잭션 롤백에 의존하기 때문에, 트랜잭션 내의 어느 포인트에서든지 했던 작업이 되돌려질 수 있다. 예를 들어 Post Http request같은 I/O작업을 수행했다고 보장하기 어렵다.
- GIL 때문에 Ruby MRI와는 성능 개선이 어려움. 프로세스 당 1 cpu 이상을 utilize할 수 없기 때문에.
예시
- concurrrent-ruby의 Tvar
🐶 Guilds
** guild는 Ractor로 이름 변경 되어 Ruby3.0에 release 되었음!!!
Ruby's Actor-like concurrent abstraction
=> 한마디로 요악하자면 Actor + CSP(채널)모델에서 영감 받은 Ruby MRI GIL문제 해결할 feature
Koichi sasada ( 현재의 루비 VM, Fiber, GC 같은거 디자인한 Ruby 코어 개발자 )에 의해 Ruby3에 제안 된 새로운 동시성 모델이다.
다음과 같은 기준에 의해 개발됨
- 새로운 모델은 Ruby2 와도 호환 가능하고 더 나은 동시성을 제공해야 할 것
- Elixir같은 immutable 데이터 구조를 고집하는 것은 엄청 느릴 것이다. Ruby는 "write" operation을 많이 사용하기 때문에.
대신 Racket 언어의 Place처럼 공유 mutable 객체를 copy하는 방식이 더 나을 것 같다. copy 속도 빨라야 함.
- mutable 객체를 공유해야 한다면 Clojure의 STM처럼 특별한 자료구조가 필요하다.
위의 아이디어들이 다음과 같은 Guild의 메인 개념으로 이어졌다.
- guild는 여러개의 Fiber를 가질 수 있는, 여러개의 Thread를 가지는 동시성 요소이다.
- guild 오너만 해당 guild의 mutable 객체에 접근할 수 있다. 그러므로 락을 사용할 필요가 없음.
- guild끼리는 채널을 통해 copy나 move(=transfer_membership) 방식으로 mutable 데이터를 주고 받을 수 있음.
- freeze된 immutable 데이터는 그냥 공유 가능. (예- numbers, symbols, true, false, deeply frozen objects.)
( 추가로 정리해 보자면 이런거다 )
문서 참고 하기 => Concurrency in Ruby3 with Guilds
You can think of a Ruby 2.x program as having a single Guild.
길드 1개당 GGL(Giant Guild Lock) 적용, 코어 1개 utilize
guild = Ruby2.x의 프로세스 같은 개념.. 프로세스 당 인터프리터 당 GIL 때문에 스레드 여러개여도 최대 1 코어만 utilize 했던..
Ruby3 부터는 프로세스 1개에 길드 n개... GIL 대신 길드 마다 GGL
길드끼리는 no shared memory. 그래서 서로 다른 길드 끼리는 락 같은 거 신경 안써도 동시에 실행가능. => actor나 csp 같은 메세지 패싱으로 데이터 주고 받을 것. 채널 통해서 copy나 move 방식으로 mutable 데이터 공유함. immutable 객체는 동기화 이슈 없으니까 장치 없이 그냥 sharing 가능함.
bank = Guild.new do
accounts = ...
while acc1, acc2, amount, channel = Guild.default_channel.receive
accounts[acc1].balance += amount
accounts[acc2].balance -= amount
channel.transfer(:finished)
end
end
channel = Guild::Channel.new
bank.transfer([acc1, acc2, 10, channel])
puts channel.receive
# => :finished
계좌 잔액에 대한 정보는 bank 길드만 가지고 있고, 고로 bank만 해당 데이터 수정 작업을 수행할 수 있다. bank 길드 외부에서는 채널을 통한 요청만 가능함.
장점
- 길드 사이에 mutable 데이터 공유가 없다는 것은 락 메커니즘을 도입할 필요가 없음을 의미한다. 따라서 데드락도 발생 안한다.
길드 사이의 통신은 안전하게 디자인 되어있다 (by channel)
- 길드에서 immutable 데이터 sharing은 독려한다. 왜냐면 이것이 여러 길드 사이에서 데이터 공유할 수 있는 가장 쉽고 빠른 방법이기 때문에. 파일 앞에 frozen_string_literal: true 를 추가함으로써 가능한 많은 데이터를 freeze 해라.
- 길드는 Ruby2와 완전히 호환가능하다. 이 말은 당신의 Ruby2.0 코드가 싱글 길드 위에서 동작할 것임을 의미함.
- MRI에서 더 나은 동시성을 할 수 있게 만듦. 드디어 싱글 루비 프로세스에서도 multiple 코어 utilization이 가능해졌다.
단점
- 성능에 대한 예측을 하긴 너무 이르지만, 길드 사이에 통신, mutable 객체 공유가 스레드 보다 오버헤드가 훨 클 것이다.
- CSP (길드 간 채널을 통한 통신), STM ( mutable 데이터 sharing에서 더 나은 성능을 위한 특수 자료구조), 한 길드 내부에서 멀티 스레드 프로그래밍 같은 너무 많은 동시성 요소가 한꺼번에 사용되어 복잡하다.
- 자원 활용 측면에서 멀티 프로세싱 보다는 싱글 프로세스에서 여러개의 길드를 운영하는 것이 훨씬 이득이겠지만, 길드는 그다지 경량이 아니다. 루비 쓰레드보다 무겁다. 이 말은 길드 만으로는 수만개의 웹소켓 커넥션을 핸들링 할 수 없을 것임을 뜻한다.
🐶결론
1편에서와 마찬가지로 만능해결책이란 없다.
CSP -> 싱글 머신에 제일 fit 한 방식.
Actor -> 여러 대의 머신으로 쉽게 scale out 할 수 있다.
STM -> 트랜잭션 방식으로 동시성 코드를 쉽게 작성할 수 있도록 해준다.
하지만 이 모든 것들은 루비에서 first citizen이 아니다. 루비의 표준 concurrency primitive는 스레드와 Fiber임.
Ruby3에는 길드(Ractor)가 릴리즈 되었으니 루비 동시성 모델에 큰 발전이 있을 것이다.
'시리즈 > Concurrency' 카테고리의 다른 글
웹서버 request 처리 전략 : threaded vs evented (0) | 2022.02.03 |
---|---|
[문서정리] I/O Bound 서버를 스케일링 하기 위한 리액터 패턴 (0) | 2022.02.02 |
[문서정리] 루비로 동시성 모델 소개하기 Part I (0) | 2022.01.30 |
yield에 관한 고찰 (0) | 2022.01.23 |
[문서정리] Implementing threads (0) | 2022.01.23 |