Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby 스레드(Thread) 완벽 가이드: 초보자도 이해하기 쉬운 튜토리얼

Ruby에서 스레드(thread)란 무엇일까요?

스레드를 사용하면 Ruby 프로그램이 여러 작업을 동시에 수행할 수 있습니다.

대표적인 활용 예시는 다음과 같습니다:

  • 여러 파일 동시에 읽기
  • 여러 웹 요청 동시에 처리하기
  • 여러 API 연결 동시에 수립하기

스레드를 활용하면 프로그램이 멀티스레드 구조로 동작하면서 전체 작업 속도가 눈에 띄게 빨라집니다.

하지만 한 가지 주의할 점이 있습니다.

Ruby의 기본 실행 환경인 MRI(Matz's Ruby Interpreter)에서는 I/O 바운드(I/O bound) 애플리케이션을 실행할 때만 스레드의 성능상 이점을 누릴 수 있습니다.

이러한 제약은 GIL(Global Interpreter Lock, 전역 인터프리터 잠금) 때문에 존재합니다. 반면 JRuby나 Rubinius 같은 대체 Ruby 인터프리터는 멀티스레딩을 온전히 활용할 수 있습니다.

그렇다면 스레드란 정확히 무엇일까요?

스레드는 '작업자(worker)', 즉 실행 단위입니다.

모든 프로세스에는 최소 하나의 스레드가 존재하며, 필요에 따라 추가로 생성할 수도 있습니다.

바로 코드 예제가 보고 싶으시겠지만, 그 전에 CPU 바운드I/O 바운드 애플리케이션의 차이를 먼저 짚고 넘어가야 합니다.

I/O 바운드 애플리케이션이란?

I/O 바운드 앱은 외부 리소스의 응답을 기다려야 하는 애플리케이션을 말합니다. 대표적으로 다음과 같은 경우가 있습니다:

  • API 요청
  • 데이터베이스(쿼리 결과)
  • 디스크 읽기

스레드는 리소스를 기다리는 동안 스스로 중단될 수 있으며, 그 사이 다른 스레드가 실행되어 대기 시간을 낭비하지 않습니다.

I/O 바운드 앱의 대표적인 예가 바로 웹 크롤러(web crawler)입니다.

크롤러는 요청을 보낼 때마다 서버의 응답을 기다려야 하고, 기다리는 동안에는 아무 작업도 할 수 없습니다.

하지만 스레드를 사용한다면 어떨까요?

한 번에 4개의 요청을 동시에 보내고, 응답이 도착하는 대로 순차적으로 처리하면 페이지를 훨씬 빠르게 수집할 수 있습니다.

이제 본격적으로 코드 예제를 살펴보겠습니다.

Ruby 스레드 생성하기

Thread.new를 호출하면 새로운 Ruby 스레드를 생성할 수 있습니다.

이때 스레드가 실행할 코드를 반드시 블록(block) 형태로 전달해야 합니다.

Thread.new { puts "hello from thread" }

아주 간단하죠?

그런데 다음 코드를 실행해 보면 스레드에서 아무런 출력이 나오지 않는 것을 확인할 수 있습니다.

t = Thread.new { puts 10**10 }
puts "hello"

원인은 Ruby가 스레드의 종료를 기다려주지 않기 때문입니다.

위 코드를 올바르게 수정하려면 스레드 객체에 join 메서드를 호출해야 합니다:

t = Thread.new { puts 10**10 }
puts "hello"
t.join

여러 개의 스레드를 생성하고 싶다면 배열에 담아두고, 각 스레드마다 join을 호출하면 됩니다.

예제:

threads = []

10.times {
  threads << Thread.new { puts 1 }
}

threads.each(&:join)

Ruby 스레드를 더 깊이 학습하고 싶다면 공식 문서를 참고하는 것도 좋습니다:

https://ruby-doc.org/core-2.5.0/Thread.html

스레드와 예외 처리

스레드 내부에서 예외(exception)가 발생하면, 프로그램 전체를 중단하거나 오류 메시지를 출력하지 않고 조용히 종료됩니다.

다음 예제를 보겠습니다:

Thread.new { raise 'hell' }

디버깅을 위해서는 문제가 발생했을 때 프로그램이 즉시 중단되도록 하는 편이 좋습니다. 그럴 때는 Thread에 다음 플래그를 true로 설정하면 됩니다:

Thread.abort_on_exception = true

반드시 스레드를 생성하기 전에 이 플래그를 설정해야 한다는 점을 잊지 마세요.

스레드 풀(Thread Pool)

처리해야 할 항목이 수백 개라면, 항목마다 스레드를 새로 생성하는 방식은 시스템 리소스를 심각하게 소모시킬 수 있습니다.

그런 코드는 대략 다음과 같은 모습일 겁니다:

pages_to_crawl = %w( index about contact ... )

pages_to_crawl.each do |page|
  Thread.new { puts page }
end

이렇게 작성하면 서버에 수백 개의 연결을 동시에 시도하게 되므로, 좋은 방법이라고 보기 어렵습니다.

이에 대한 해결책 중 하나가 바로 스레드 풀입니다.

스레드 풀을 사용하면 특정 시점에 활성화되는 스레드의 개수를 제어할 수 있습니다.

물론 직접 풀을 구현할 수도 있지만, 권장하지 않습니다. 아래 예제에서는 celluloid 젬(gem)을 활용해 이를 손쉽게 처리합니다.

참고: Celluloid는 현재 유지보수가 중단된 상태이지만, 워커 풀(worker pool)의 핵심 개념 자체는 여전히 유효합니다.

require 'celluloid'

class Worker
  include Celluloid

  def process_page(url)
    puts url
  end
end

pages_to_crawl = %w( index about contact products ... )
worker_pool    = Worker.pool(size: 5)

# 반환값을 수집해야 한다면 'futures' 기능을 참고하세요.
pages_to_crawl.each do |page|
   worker_pool.process_page(page)
end

이번에는 단 5개의 스레드만 실행되며, 각 스레드는 작업을 마칠 때마다 다음 항목을 자동으로 가져와 처리합니다.

경쟁 상태(Race Condition)와 기타 주의사항

지금까지의 내용이 매력적으로 들리겠지만, 코드 곳곳에 무분별하게 스레드를 적용하기 전에 반드시 알아야 할 동시성(concurrency) 관련 문제들이 있습니다.

예를 들어, 스레드는 경쟁 상태(race condition)에 취약합니다.

경쟁 상태란 여러 작업이 의도한 순서대로 실행되지 않아 데이터가 꼬이거나 예측 불가능한 결과가 발생하는 상황을 말합니다.

또 다른 대표적인 문제는 교착 상태(deadlock)입니다. 한 스레드가 뮤텍스(mutex) 같은 잠금 메커니즘을 통해 특정 리소스에 대한 독점적 접근권을 확보한 뒤 이를 영원히 해제하지 않으면, 나머지 모든 스레드가 해당 리소스에 접근하지 못하게 됩니다.

이러한 문제들을 피하려면 원시(raw) 스레드를 직접 다루기보다, 세부적인 동시성 처리를 대신 맡아주는 검증된 젬을 사용하는 것이 안전합니다.

활용할 만한 스레드 관련 젬

앞서 스레드 풀에 celluloid를 사용해 보았는데, 그 외에도 동시성 처리에 특화된 유용한 젬들이 많습니다:

  • https://github.com/grosser/parallel
  • https://github.com/chadrem/workers
  • https://github.com/ruby-concurrency/concurrent-ruby

여기까지입니다. 이 글을 통해 Ruby 스레드에 대한 유용한 지식을 얻어가셨기를 바랍니다!

내용이 도움이 되었다면 주변 친구들에게 공유해서 함께 배워보세요.