지난 루비 매직(Ruby Magic) 아티클 '동시성 마스터하기'에서는 루비 개발자가 활용할 수 있는 세 가지 동시성 구현 방법을 소개했습니다. 이번 글은 각 방법을 하나씩 깊이 있게 파헤치는 3부작 시리즈의 첫 번째 편입니다.
첫 번째 주제는 바로 다중 프로세스(Multi-process)입니다. 이 방식에서는 마스터 프로세스가 자기 자신을 여러 워커 프로세스로 복제(포크)하고, 실제 작업은 워커 프로세스가 수행하며 마스터 프로세스는 이들을 관리하는 역할을 맡습니다.
본문 예제에 사용된 전체 소스 코드는 GitHub에 공개되어 있으니 직접 내려받아 실험해 보실 수 있습니다.
채팅 시스템 만들기
동시성을 제대로 이해하려면 채팅 시스템을 만들어 보는 것이 좋은 출발점입니다. 우리에게 필요한 것은 여러 클라이언트와 동시에 연결을 유지할 수 있는 채팅 서버 컴포넌트입니다. 이 서버는 한 클라이언트로부터 받은 메시지를 나머지 모든 접속 중인 클라이언트에게 전달해야 합니다.
화면 왼쪽 탭에서는 채팅 서버가, 오른쪽 탭 두 곳에서는 채팅 클라이언트가 실행되고 있습니다. 한 클라이언트가 보낸 메시지는 다른 모든 클라이언트에게 전달됩니다.
채팅 클라이언트
이 글의 초점은 채팅 서버에 맞춰져 있지만, 서버와 통신하려면 먼저 채팅 클라이언트가 필요합니다. 아래 코드는 아주 단순한 형태의 클라이언트입니다. (더 완성도 높은 예제는 GitHub에서 확인할 수 있습니다.)
# client.rb
# $ ruby client.rb
require 'socket'
client = TCPSocket.open(ARGV[0], 2000)
Thread.new do
while line = client.gets
puts line.chop
end
end
while input = STDIN.gets.chomp
client.puts input
end클라이언트는 2000번 포트에서 실행 중인 서버와 TCP 연결을 맺습니다. 연결이 성립하면 스레드를 하나 생성해 서버가 보내오는 모든 메시지를 터미널에 출력함으로써 대화 내용을 화면에서 확인할 수 있게 합니다. 마지막으로 while 루프를 통해 사용자가 입력한 문장을 서버로 전송하고, 서버는 이를 다른 모든 클라이언트에게 뿌려줍니다.
채팅 서버
이 예제에서 클라이언트는 다른 클라이언트들과 대화하기 위해 채팅 서버에 접속합니다. 세 가지 동시성 방식 모두에서 루비 표준 라이브러리가 제공하는 동일한 TCP 서버를 사용합니다.
# server_processes.rb
# $ ruby server_processes.rb
require 'socket'
puts 'Starting server on port 2000'
server = TCPServer.open(2000)여기까지의 코드는 세 가지 동시성 모델 모두에서 동일합니다. 이후 각 모델의 채팅 서버는 두 가지 상황을 처리해야 합니다.
- 클라이언트의 새로운 연결 요청을 수락한다.
- 클라이언트로부터 받은 메시지를 다른 모든 클라이언트에게 전달한다.
다중 프로세스 채팅 서버
다중 프로세스 방식으로 위 두 가지 상황을 처리하려면, 클라이언트 연결마다 프로세스를 하나씩 생성합니다. 각 프로세스는 해당 클라이언트가 주고받는 모든 메시지를 담당합니다. 이러한 프로세스는 원래 서버 프로세스를 포크(fork)하여 만들 수 있습니다.
프로세스 포킹
fork 메서드를 호출하면 현재 프로세스의 상태를 그대로 복사한 새 프로세스가 만들어집니다.
포크된 프로세스는 고유한 프로세스 ID를 가지며, top이나 Activity Monitor 같은 도구에서 별개의 프로세스로 표시됩니다. 대략 다음과 같은 모습입니다.
처음 시작된 프로세스를 마스터 프로세스라고 부르고, 마스터로부터 포크된 프로세스들을 워커 프로세스라고 부릅니다.
새로 포크된 워커 프로세스들은 완전히 독립된 프로세스이기 때문에 마스터 프로세스와 메모리를 공유할 수 없습니다. 따라서 프로세스 간 통신을 위한 별도의 수단이 필요합니다.
유닉스 파이프
프로세스 간 통신에는 유닉스 파이프(Unix pipe)를 사용합니다. 유닉스 파이프는 두 프로세스 사이에 양방향 바이트 스트림을 열어주며, 한 프로세스에서 다른 프로세스로 데이터를 전송할 때 활용할 수 있습니다. 다행히 루비는 이러한 파이프를 손쉽게 다룰 수 있는 좋은 래퍼(wrapper)를 제공하기 때문에 바퀴를 다시 발명할 필요가 없습니다.
아래 예제에서는 읽기 끝단과 쓰기 끝단을 가진 파이프를 루비로 생성한 뒤 마스터 프로세스를 fork 합니다. fork에 넘겨진 블록 안의 코드는 포크된 프로세스에서 실행되고, 원래 프로세스는 이 블록 이후부터 계속 진행됩니다. 그런 다음 포크된 프로세스에서 원래 프로세스로 메시지를 전송합니다.
reader, writer = IO.pipe
fork do
# This is running in the forked process.
writer.puts 'Hello from the forked process'
end
# This is running in the original process, it will puts the
# message from the forked process.
puts reader.gets파이프를 활용하면 서로 완전히 격리된 프로세스 사이에서도 통신이 가능합니다.
채팅 서버 구현
먼저 모든 클라이언트의 파이프와 그 "writer"(파이프의 쓰기 끝단)를 추적하기 위한 배열을 준비합니다. 이 배열 덕분에 클라이언트들과 통신할 수 있습니다. 그다음에는 클라이언트로부터 들어온 모든 메시지를 다른 클라이언트들에게 전달하도록 설정합니다.
client_writers = []
master_reader, master_writer = IO.pipe
write_incoming_messages_to_child_processes(master_reader, client_writers)write_incoming_messages_to_child_processes의 구체적인 동작 방식이 궁금하다면 GitHub에서 구현 코드를 확인할 수 있습니다.
새 연결 수락하기
이제 들어오는 연결을 수락하고 파이프를 설정해야 합니다. 새로 생성된 writer는 client_writers 배열에 추가됩니다. 메인 프로세스는 이 배열을 순회하면서 각 워커 프로세스의 파이프에 데이터를 씀으로써 메시지를 전달할 수 있습니다.
그다음 마스터 프로세스를 포크하고, 포크된 워커 프로세스 내부의 코드가 클라이언트 연결을 처리하게 됩니다.
loop do
while socket = server.accept
# Create a client reader and writer so that the master
# process can write messages back to us.
client_reader, client_writer = IO.pipe
# Put the client writer on the list of writers so the
# master process can write to them.
client_writers.push(client_writer)
# Fork child process, everything in the fork block
# only runs in the child process.
fork do
# Handle connection
end
end
end클라이언트 연결 처리하기
클라이언트 연결 자체를 처리하는 부분도 필요합니다.
포크된 프로세스는 우선 클라이언트로부터 닉네임을 받는 것으로 시작합니다(클라이언트는 기본적으로 닉네임을 전송합니다). 이후 write_incoming_messages_to_client 안에서 스레드를 시작해 메인 프로세스가 보내는 메시지를 계속 듣습니다.
마지막으로 포크된 프로세스는 루프를 돌며 들어오는 메시지를 감시하고 이를 마스터 프로세스로 전송합니다. 마스터 프로세스는 해당 메시지가 다른 워커 프로세스들에게 전달되도록 보장합니다.
nickname = read_line_from(socket)
puts "#{Process.pid}: Accepted connection from #{nickname}"
write_incoming_messages_to_client(nickname, client_reader, socket)
# Read incoming messages from the client.
while incoming = read_line_from(socket)
master_writer.puts "#{nickname}: #{incoming}"
end
puts "#{Process.pid}: Disconnected #{nickname}"완성된 채팅 시스템
이제 채팅 시스템 전체가 정상적으로 작동합니다! 하지만 잘 살펴보면, 멀티프로세싱을 활용한 프로그램은 작성 난이도가 꽤 높고 많은 리소스를 소모한다는 사실을 알 수 있습니다. 반면 장점은 무엇보다 견고하다는 점입니다. 자식 프로세스 하나가 크래시가 나더라도 나머지 시스템은 계속 정상적으로 동작합니다. 예제 코드를 실행한 뒤 kill -9 <process-id> 명령으로 프로세스 하나를 종료해 보면 이를 직접 확인할 수 있습니다(프로세스 ID는 서버 로그 출력에서 찾을 수 있습니다).
다음 글에서는 동일한 채팅 시스템을 스레드만으로 구현해 보겠습니다. 이렇게 하면 하나의 프로세스와 더 적은 메모리만으로 같은 기능을 갖춘 서버를 운영할 수 있습니다.