Skip to content

Commit 2ad87f7

Browse files
byrootusiegl00
andcommitted
Add Concurrent.usable_processor_count that is cgroups aware
Closes: #1035 A running gag since the introduction of containerization is software that starts one process per logical or physical core while running inside a container with a restricted CPU quota and totally blowing up memory usage in containerized environments. The proper question to ask is how many CPU cores are usable, not how many the machine has. To do that we have to read the cgroup info from `/sys`. There is two way of doing it depending on the version of cgroups used. Co-Authored-By: usiegl00 <[email protected]>
1 parent e9748af commit 2ad87f7

File tree

2 files changed

+127
-0
lines changed

2 files changed

+127
-0
lines changed

lib/concurrent-ruby/concurrent/utility/processor_counter.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class ProcessorCounter
1111
def initialize
1212
@processor_count = Delay.new { compute_processor_count }
1313
@physical_processor_count = Delay.new { compute_physical_processor_count }
14+
@cpu_quota = Delay.new { compute_cpu_quota }
1415
end
1516

1617
def processor_count
@@ -21,6 +22,23 @@ def physical_processor_count
2122
@physical_processor_count.value
2223
end
2324

25+
def available_processor_count
26+
cpu_count = processor_count.to_f
27+
quota = cpu_quota
28+
29+
return cpu_count if quota.nil?
30+
31+
if quota > cpu_count
32+
cpu_count
33+
else
34+
quota
35+
end
36+
end
37+
38+
def cpu_quota
39+
@cpu_quota.value
40+
end
41+
2442
private
2543

2644
def compute_processor_count
@@ -60,6 +78,24 @@ def compute_physical_processor_count
6078
rescue
6179
return 1
6280
end
81+
82+
def compute_cpu_quota
83+
if RbConfig::CONFIG["target_os"].match(/linux/i)
84+
if File.exist?("/sys/fs/cgroup/cpu.max")
85+
# cgroups v2: https://docs.kernel.org/admin-guide/cgroup-v2.html#cpu-interface-files
86+
cpu_max = File.read("/sys/fs/cgroup/cpu.max")
87+
return nil if cpu_max.start_with?("max ") # no limit
88+
max, period = cpu_max.split.map(&:to_f)
89+
max / period
90+
elsif File.exist?("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us")
91+
# cgroups v1: https://kernel.googlesource.com/pub/scm/linux/kernel/git/glommer/memcg/+/cpu_stat/Documentation/cgroups/cpu.txt
92+
max = File.read("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us").to_i
93+
return nil if max == 0
94+
period = File.read("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us").to_f
95+
max / period
96+
end
97+
end
98+
end
6399
end
64100
end
65101

@@ -107,4 +143,20 @@ def self.processor_count
107143
def self.physical_processor_count
108144
processor_counter.physical_processor_count
109145
end
146+
147+
# Number of processors usable for process scheduling.
148+
# Returns `nil` if there is no quota, or a `Float` if the
149+
# process is inside a cgroup with a dedicated CPU quota (typically Docker).
150+
#
151+
# For performance reasons the calculated value will be memoized on the first
152+
# call.
153+
#
154+
# @return [nil, Float] number of usable processors seen by the OS or Java runtime
155+
def self.available_processor_count
156+
processor_counter.available_processor_count
157+
end
158+
159+
def self.cpu_quota
160+
processor_counter.cpu_quota
161+
end
110162
end

spec/concurrent/utility/processor_count_spec.rb

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,79 @@ module Concurrent
1717
expect(Concurrent::physical_processor_count).to be >= 1
1818
end
1919
end
20+
21+
RSpec.describe '#cpu_quota' do
22+
23+
let(:counter) { Concurrent::Utility::ProcessorCounter.new }
24+
25+
it 'returns #compute_cpu_quota' do
26+
expect(Concurrent::cpu_quota).to be == counter.cpu_quota
27+
end
28+
29+
it 'returns nil if no quota is detected' do
30+
if RbConfig::CONFIG["target_os"].match?(/linux/i)
31+
expect(File).to receive(:exist?).twice.and_return(nil) # Checks for cgroups V1 and V2
32+
end
33+
expect(counter.cpu_quota).to be_nil
34+
end
35+
36+
it 'returns nil if cgroups v2 sets no limit' do
37+
expect(RbConfig::CONFIG).to receive(:[]).with("target_os").and_return("linux")
38+
expect(File).to receive(:exist?).with("/sys/fs/cgroup/cpu.max").and_return(true)
39+
expect(File).to receive(:read).with("/sys/fs/cgroup/cpu.max").and_return("max 100000\n")
40+
expect(counter.cpu_quota).to be_nil
41+
end
42+
43+
it 'returns a float if cgroups v2 sets a limit' do
44+
expect(RbConfig::CONFIG).to receive(:[]).with("target_os").and_return("linux")
45+
expect(File).to receive(:exist?).with("/sys/fs/cgroup/cpu.max").and_return(true)
46+
expect(File).to receive(:read).with("/sys/fs/cgroup/cpu.max").and_return("150000 100000\n")
47+
expect(counter.cpu_quota).to be == 1.5
48+
end
49+
50+
it 'returns nil if cgroups v1 sets no limit' do
51+
expect(RbConfig::CONFIG).to receive(:[]).with("target_os").and_return("linux")
52+
expect(File).to receive(:exist?).with("/sys/fs/cgroup/cpu.max").and_return(false)
53+
expect(File).to receive(:exist?).with("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us").and_return(true)
54+
55+
expect(File).to receive(:read).with("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us").and_return("max\n")
56+
expect(counter.cpu_quota).to be_nil
57+
end
58+
59+
it 'returns a float if cgroups v1 sets a limit' do
60+
expect(RbConfig::CONFIG).to receive(:[]).with("target_os").and_return("linux")
61+
expect(File).to receive(:exist?).with("/sys/fs/cgroup/cpu.max").and_return(false)
62+
expect(File).to receive(:exist?).with("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us").and_return(true)
63+
64+
expect(File).to receive(:read).with("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us").and_return("150000\n")
65+
expect(File).to receive(:read).with("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us").and_return("100000\n")
66+
expect(counter.cpu_quota).to be == 1.5
67+
end
68+
69+
end
70+
71+
RSpec.describe '#available_processor_count' do
72+
73+
it 'returns #processor_count if #cpu_quota is nil' do
74+
expect(Concurrent::processor_counter).to receive(:cpu_quota).and_return(nil)
75+
available_processor_count = Concurrent::available_processor_count
76+
expect(available_processor_count).to be == Concurrent::processor_count
77+
expect(available_processor_count).to be_a Float
78+
end
79+
80+
it 'returns #processor_count if #cpu_quota is higher' do
81+
expect(Concurrent::processor_counter).to receive(:cpu_quota).and_return(Concurrent::processor_count.to_f * 2)
82+
available_processor_count = Concurrent::available_processor_count
83+
expect(available_processor_count).to be == Concurrent::processor_count
84+
expect(available_processor_count).to be_a Float
85+
end
86+
87+
it 'returns #cpu_quota if #cpu_quota is lower than #processor_count' do
88+
expect(Concurrent::processor_counter).to receive(:cpu_quota).and_return(Concurrent::processor_count.to_f / 2)
89+
available_processor_count = Concurrent::available_processor_count
90+
expect(available_processor_count).to be == Concurrent::processor_count.to_f / 2
91+
expect(available_processor_count).to be_a Float
92+
end
93+
94+
end
2095
end

0 commit comments

Comments
 (0)