Ruby에서 메모리 문제 찾기: 확실한 가이드
게시 됨: 2022-03-11메모리 문제가 발생하지 않는 운이 좋은 Ruby 개발자가 있다고 확신하지만 나머지 우리에게는 메모리 사용량이 감당할 수 없는 부분을 찾아 해결하는 것이 엄청나게 어렵습니다. 다행히도 최신 Ruby(2.1 이상)를 사용하는 경우 일반적인 문제를 처리하는 데 사용할 수 있는 몇 가지 훌륭한 도구와 기술이 있습니다. 저 혼자만의 감정일지라도 메모리 최적화는 재미있고 보람이 있다고 말할 수 있습니다.
모든 형태의 최적화와 마찬가지로 코드 복잡성이 추가될 가능성이 높으므로 측정 가능하고 상당한 이득이 없으면 수행할 가치가 없습니다.
여기에 설명된 모든 것은 표준 MRI Ruby 버전 2.2.4를 사용하여 수행되지만 다른 2.1+ 버전도 유사하게 작동해야 합니다.
메모리 누수가 아닙니다!
메모리 문제가 발견되면 메모리 누수가 있다는 결론을 내리기가 쉽습니다. 예를 들어, 웹 애플리케이션에서 서버를 가동한 후 동일한 엔드포인트에 대한 반복적인 호출이 각 요청과 함께 메모리 사용량을 계속 증가시키는 것을 볼 수 있습니다. 합법적인 메모리 누수가 발생하는 경우가 분명히 있지만 실제로 누수가 아닌 이와 동일한 모양의 메모리 문제가 메모리 문제보다 훨씬 많습니다.
(인도적인) 예로서, 해시의 큰 배열을 반복적으로 만들고 그것을 버리는 약간의 Ruby 코드를 봅시다. 먼저, 이 게시물의 예제 전체에서 공유할 몇 가지 코드는 다음과 같습니다.
# common.rb require "active_record" require "active_support/all" require "get_process_mem" require "sqlite3" ActiveRecord::Base.establish_connection( adapter: "sqlite3", database: "people.sqlite3" ) class Person < ActiveRecord::Base; end def print_usage(description) mb = GetProcessMem.new.mb puts "#{ description } - MEMORY USAGE(MB): #{ mb.round }" end def print_usage_before_and_after print_usage("Before") yield print_usage("After") end def random_name (0...20).map { (97 + rand(26)).chr }.join end
그리고 어레이 빌더:
# build_arrays.rb require_relative "./common" ARRAY_SIZE = 1_000_000 times = ARGV.first.to_i print_usage(0) (1..times).each do |n| foo = [] ARRAY_SIZE.times { foo << {some: "stuff"} } print_usage(n) end
get_process_mem gem은 현재 Ruby 프로세스에서 사용 중인 메모리를 가져오는 편리한 방법입니다. 위에서 설명한 것과 동일한 동작, 즉 메모리 사용량이 지속적으로 증가하는 것을 볼 수 있습니다.
$ ruby build_arrays.rb 10 0 - MEMORY USAGE(MB): 17 1 - MEMORY USAGE(MB): 330 2 - MEMORY USAGE(MB): 481 3 - MEMORY USAGE(MB): 492 4 - MEMORY USAGE(MB): 559 5 - MEMORY USAGE(MB): 584 6 - MEMORY USAGE(MB): 588 7 - MEMORY USAGE(MB): 591 8 - MEMORY USAGE(MB): 603 9 - MEMORY USAGE(MB): 613 10 - MEMORY USAGE(MB): 621
그러나 더 많은 반복을 실행하면 결국 정체됩니다.
$ ruby build_arrays.rb 40 0 - MEMORY USAGE(MB): 9 1 - MEMORY USAGE(MB): 323 ... 32 - MEMORY USAGE(MB): 700 33 - MEMORY USAGE(MB): 699 34 - MEMORY USAGE(MB): 698 35 - MEMORY USAGE(MB): 698 36 - MEMORY USAGE(MB): 696 37 - MEMORY USAGE(MB): 696 38 - MEMORY USAGE(MB): 696 39 - MEMORY USAGE(MB): 701 40 - MEMORY USAGE(MB): 697
이 정체기에 도달하면 실제 메모리 누수가 아니거나 메모리 누수가 너무 작아서 나머지 메모리 사용량에 비해 눈에 띄지 않는다는 특징이 있습니다. 직관적이지 않은 것은 첫 번째 반복 후에도 메모리 사용량이 계속 증가하는 이유입니다. 결국, 그것은 큰 어레이를 만들었지만 즉시 그것을 버리고 같은 크기의 새 어레이를 만들기 시작했습니다. 이전 어레이에서 확보한 공간을 그대로 사용할 수 없습니까? 우리의 문제를 설명하는 대답은 아니오입니다. 가비지 수집기를 조정하는 것 외에는 실행 시간을 제어할 수 없으며 build_arrays.rb
예제에서 볼 수 있는 것은 폐기된 이전 개체의 가비지 수집 이전에 새로운 메모리 할당이 이루어지고 있다는 것입니다.
이것은 Ruby에 특정한 일종의 끔찍한 메모리 관리 문제가 아니라 일반적으로 가비지 수집 언어에 적용된다는 점을 지적해야 합니다. 이것에 대해 스스로를 안심시키기 위해 Go로 본질적으로 동일한 예를 재현했고 비슷한 결과를 보았습니다. 그러나 이러한 종류의 메모리 문제를 쉽게 만들 수 있는 Ruby 라이브러리가 있습니다.
분할 및 정복
따라서 큰 데이터 덩어리로 작업해야 하는 경우 문제에 많은 RAM을 던질 운명입니까? 고맙게도 그렇지 않습니다. build_arrays.rb
예제를 사용하여 어레이 크기를 줄이면 어레이 크기에 대략 비례하는 메모리 사용량이 정체되는 지점이 감소하는 것을 볼 수 있습니다.
즉, 처리하기 위해 작업을 더 작은 조각으로 나누고 한 번에 너무 많은 개체가 존재하지 않도록 하면 메모리 공간을 크게 줄일 수 있습니다. 불행히도 이는 종종 멋지고 깨끗한 코드를 가져와 메모리 효율적인 방식으로 동일한 작업을 수행하는 더 많은 코드로 바꾸는 것을 의미합니다.
메모리 사용량 핫스팟 격리
실제 코드베이스에서 메모리 문제의 원인은 build_arrays.rb
예제처럼 명확하지 않을 수 있습니다. 문제의 원인에 대해 잘못된 가정을 하기 쉽기 때문에 실제로 문제를 파고 해결하기 전에 메모리 문제를 분리하는 것이 중요합니다.
나는 일반적으로 메모리 문제를 추적하기 위해 두 가지 접근 방식을 종종 조합하여 사용합니다. 코드는 그대로 두고 주변에 프로파일러를 래핑하는 것이고, 다른 하나는 문제가 될 수 있다고 의심되는 코드의 다른 부분을 비활성화/활성화하면서 프로세스의 메모리 사용량을 모니터링하는 것입니다. 여기에서 프로파일링을 위해 memory_profiler를 사용할 것이지만 ruby-prof는 또 다른 인기 있는 옵션이고 derailed_benchmarks에는 훌륭한 Rails 관련 기능이 있습니다.
다음은 메모리 사용량을 가장 많이 늘리는 단계가 무엇인지 즉시 명확하지 않을 수 있는 많은 메모리를 사용하는 몇 가지 코드입니다.
# people.rb require_relative "./common" def run(number) Person.delete_all names = number.times.map { random_name } names.each do |name| Person.create(name: name) end records = Person.all.to_a File.open("people.txt", "w") { |out| out << records.to_json } end
get_process_mem을 사용하여 많은 Person
레코드가 생성될 때 많은 메모리를 사용하는지 빠르게 확인할 수 있습니다.
# before_and_after.rb require_relative "./people" print_usage_before_and_after do run(ARGV.shift.to_i) end
결과:
$ ruby before_and_after.rb 10000 Before - MEMORY USAGE(MB): 37 After - MEMORY USAGE(MB): 96
코드를 살펴보면 많은 메모리를 사용하기에 좋은 후보처럼 보이는 여러 단계가 있습니다. 문자열의 큰 배열 만들기, Active Record 관계에서 #to_a
를 호출하여 Active Record 개체의 큰 배열 만들기(좋은 생각은 아닙니다. , 그러나 데모 목적으로 수행됨) 및 Active Record 개체의 배열을 직렬화합니다.
그런 다음 이 코드를 프로파일링하여 메모리 할당이 발생하는 위치를 확인할 수 있습니다.
# profile.rb require "memory_profiler" require_relative "./people" report = MemoryProfiler.report do run(1000) end report.pretty_print(to_file: "profile.txt")
프로파일러 자체가 많은 메모리를 사용하고 이미 높은 메모리 사용량을 유발하는 코드를 프로파일링할 때 실제로 메모리 고갈로 이어질 수 있기 때문에 여기에서 run
하기 위해 공급되는 숫자는 이전 예제의 1/10입니다.
결과 파일은 다소 길며 gem, 파일 및 위치 수준에서 메모리와 객체 할당 및 보존을 포함합니다. 탐색할 정보가 풍부하지만 여기에 몇 가지 흥미로운 스니펫이 있습니다.
allocated memory by gem ----------------------------------- 17520444 activerecord-4.2.6 7305511 activesupport-4.2.6 2551797 activemodel-4.2.6 2171660 arel-6.0.3 2002249 sqlite3-1.3.11 ... allocated memory by file ----------------------------------- 2840000 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ e_support/hash_with_indifferent_access.rb 2006169 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active _record/type/time_value.rb 2001914 /Users/bruz/code/mem_test/people.rb 1655493 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active _record/connection_adapters/sqlite3_adapter.rb 1628392 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ e_support/json/encoding.rb
우리는 records
배열의 모든 객체를 인스턴스화하거나 #to_json
을 사용한 직렬화를 가리키는 것처럼 보이는 Active Record 내에서 가장 많은 할당이 발생하는 것을 봅니다. 다음으로 이러한 용의자를 비활성화하면서 프로파일러 없이 메모리 사용량을 테스트할 수 있습니다. records
검색을 비활성화할 수 없고 여전히 직렬화 단계를 수행할 수 있으므로 먼저 직렬화를 비활성화해 보겠습니다.
# File.open("people.txt", "w") { |out| out << records.to_json }
결과:
$ ruby before_and_after.rb 10000 Before: 36 MB After: 47 MB
그것은 실제로 대부분의 메모리가 가는 곳으로 보이며, 전후 메모리 델타를 건너뛰면 81% 감소합니다. 또한 대량의 레코드를 강제로 생성하지 않으면 어떻게 되는지 알 수 있습니다.

# records = Person.all.to_a records = Person.all # File.open("people.txt", "w") { |out| out << records.to_json }
결과:
$ ruby before_and_after.rb 10000 Before: 36 MB After: 40 MB
직렬화를 비활성화하는 것보다 훨씬 적은 양이지만 메모리 사용량도 감소합니다. 따라서 이 시점에서 우리는 가장 큰 원인을 알고 있으며 이 데이터를 기반으로 무엇을 최적화할지 결정할 수 있습니다.
여기의 예는 고안된 것이지만 접근 방식은 일반적으로 적용할 수 있습니다. 프로파일러 결과는 문제가 있는 코드의 정확한 지점을 가리키지 않을 수 있으며 잘못 해석될 수도 있으므로 코드 섹션을 켜고 끄는 동안 실제 메모리 사용량을 확인하여 후속 조치를 취하는 것이 좋습니다. 다음으로 메모리 사용량이 문제가 되는 몇 가지 일반적인 경우와 이를 최적화하는 방법을 살펴보겠습니다.
역직렬화
메모리 문제의 일반적인 원인은 XML, JSON 또는 기타 데이터 직렬화 형식에서 대량의 데이터를 역직렬화하는 것입니다. JSON.parse
또는 Active Support의 Hash.from_xml
과 같은 메서드를 사용하면 매우 편리하지만, 로드하는 데이터가 클 때 메모리에 로드되는 결과 데이터 구조가 엄청날 수 있습니다.
데이터 소스를 제어할 수 있는 경우 필터링 또는 페이지 매김 지원 추가와 같이 수신하는 데이터의 양을 제한하는 작업을 수행할 수 있습니다. 그러나 외부 소스이거나 제어할 수 없는 경우 스트리밍 디시리얼라이저를 사용하는 또 다른 옵션이 있습니다. XML의 경우 Ox가 하나의 옵션이고 JSON의 경우 yajl-ruby에 대한 경험이 많지는 않지만 비슷하게 작동하는 것으로 보입니다.
다음은 Hash#from_xml
을 사용하여 1.7MB XML 파일을 구문 분석하는 예입니다.
# parse_with_from_xml.rb require_relative "./common" print_usage_before_and_after do # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__)) hash = Hash.from_xml(file)["mondial"]["continent"] puts hash.map { |c| c["name"] }.join(", ") end
$ ruby parse_with_from_xml.rb Before - MEMORY USAGE(MB): 37 Europe, Asia, America, Australia/Oceania, Africa After - MEMORY USAGE(MB): 164
1.7MB 파일에 111MB! 이것은 분명히 잘 확장되지 않을 것입니다. 다음은 스트리밍 파서 버전입니다.
# parse_with_ox.rb require_relative "./common" require "ox" class Handler < ::Ox::Sax def initialize(&block) @yield_to = block end def start_element(name) case name when :continent @in_continent = true end end def end_element(name) case name when :continent @yield_to.call(@name) if @name @in_continent = false @name = nil end end def attr(name, value) case name when :name @name = value if @in_continent end end end print_usage_before_and_after do # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__)) continents = [] handler = Handler.new do |continent| continents << continent end Ox.sax_parse(handler, file) puts continents.join(", ") end
$ ruby parse_with_ox.rb Before - MEMORY USAGE(MB): 37 Europe, Asia, America, Australia/Oceania, Africa After - MEMORY USAGE(MB): 37
이것은 무시할 수 있는 메모리 증가를 가져오고 훨씬 더 큰 파일을 처리할 수 있어야 합니다. 그러나 절충안은 이제 이전에 필요하지 않은 28줄의 핸들러 코드가 있다는 것입니다. 이는 오류가 발생하기 쉬우며 프로덕션 사용을 위해 주변에 몇 가지 테스트가 있어야 합니다.
직렬화
메모리 사용 핫스팟 분리에 대한 섹션에서 보았듯이 직렬화는 높은 메모리 비용을 가질 수 있습니다. 다음은 이전의 people.rb
의 핵심 부분입니다.
# to_json.rb require_relative "./common" print_usage_before_and_after do File.open("people.txt", "w") { |out| out << Person.all.to_json } end
데이터베이스에 있는 100,000개의 레코드로 이것을 실행하면 다음을 얻습니다.
$ ruby to_json.rb Before: 36 MB After: 505 MB
여기서 #to_json
을 호출할 때의 문제는 모든 레코드에 대해 개체를 인스턴스화한 다음 JSON으로 인코딩한다는 것입니다. 한 번에 하나의 레코드 개체만 있으면 되도록 JSON 레코드를 레코드별로 생성하면 메모리 사용량이 크게 줄어듭니다. 인기 있는 Ruby JSON 라이브러리는 이 문제를 처리하는 것으로 보이지 않지만 일반적으로 권장되는 접근 방식은 JSON 문자열을 수동으로 빌드하는 것입니다. 이를 수행하기 위한 멋진 API를 제공하는 json-write-stream gem이 있으며 우리의 예제를 다음과 같이 변환합니다.
# json_stream.rb require_relative "./common" require "json-write-stream" print_usage_before_and_after do file = File.open("people.txt", "w") JsonWriteStream.from_stream(file) do |writer| writer.write_object do |obj_writer| obj_writer.write_array("people") do |arr_writer| Person.find_each do |people| arr_writer.write_element people.as_json end end end end end
다시 한 번, 최적화로 인해 더 많은 코드가 제공되었지만 결과는 그만한 가치가 있는 것 같습니다.
$ ruby json_stream.rb Before: 36 MB After: 56 MB
게으른
Ruby 2.0부터 추가된 훌륭한 기능은 열거자를 게으르게 만드는 기능입니다. 이것은 열거자에서 메서드를 연결할 때 메모리 사용을 개선하는 데 유용합니다. 게으르지 않은 코드부터 시작해 보겠습니다.
# not_lazy.rb require_relative "./common" number = ARGV.shift.to_i print_usage_before_and_after do names = number.times .map { random_name } .map { |name| name.capitalize } .map { |name| "#{ name } Jr." } .select { |name| name[0] == "X" } .to_a end
결과:
$ ruby not_lazy.rb 1_000_000 Before: 36 MB After: 546 MB
여기서 일어나는 일은 체인의 각 단계에서 열거자의 모든 요소를 반복하여 호출된 체인의 후속 메서드가 있는 배열을 생성하는 것입니다. 이것을 lazy로 만들 때 어떤 일이 발생하는지 봅시다. 이것은 우리가 times
에서 얻은 열거자에 대해 lazy
에 대한 호출을 추가하기만 하면 됩니다.
# lazy.rb require_relative "./common" number = ARGV.shift.to_i print_usage_before_and_after do names = number.times.lazy .map { random_name } .map { |name| name.capitalize } .map { |name| "#{ name } Jr." } .select { |name| name[0] == "X" } .to_a end
결과:
$ ruby lazy.rb 1_000_000 Before: 36 MB After: 52 MB
마지막으로 추가 코드를 많이 추가하지 않고도 엄청난 메모리 사용량을 제공하는 예제입니다! 예를 들어 각 항목이 데이터베이스에 저장되어 잊어버릴 수 있는 경우와 같이 마지막에 결과를 누적할 필요가 없다면 메모리 사용량이 훨씬 줄어들 것입니다. 체인의 끝에서 지연 열거 가능한 평가를 만들려면 force
에 대한 최종 호출을 추가하기만 하면 됩니다.
예제에 대해 주목해야 할 또 다른 사항은 체인이 호출될 때마다 정수를 생성하는 열거자를 반환하기 때문에 매우 적은 메모리를 사용하는 lazy
이전의 times
호출로 시작한다는 것입니다. 따라서 체인 시작 부분에 큰 배열 대신 열거형을 사용할 수 있다면 도움이 될 것입니다.
일종의 처리 파이프라인에 느리게 피드하기 위해 열거형을 구축하는 실제 응용 프로그램 중 하나는 페이지가 매겨진 데이터를 처리하는 것입니다. 따라서 모든 페이지를 요청하고 하나의 큰 배열에 넣는 대신 모든 페이지 매김 세부 정보를 멋지게 숨기는 열거자를 통해 노출될 수 있습니다. 다음과 같이 보일 수 있습니다.
def records Enumerator.new do |yielder| has_more = true page = 1 while has_more response = fetch(page) response.records.each { |record| yielder << record } page += 1 has_more = response.has_more end end end
결론
우리는 Ruby에서 메모리 사용에 대한 몇 가지 특성화 작업을 수행했으며 메모리 문제를 추적하기 위한 몇 가지 일반적인 도구와 몇 가지 일반적인 경우와 문제를 개선하는 방법을 살펴보았습니다. 우리가 조사한 일반적인 사례는 결코 포괄적이지 않으며 개인적으로 직면한 종류의 문제로 인해 크게 편향되어 있습니다. 그러나 가장 큰 이점은 코드가 메모리 사용에 미치는 영향에 대해 생각하는 것뿐일 수 있습니다.