Skip to content

Commit a5f09a5

Browse files
byrootusiegl00
authored 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 a5f09a5

File tree

2 files changed

+140
-0
lines changed

2 files changed

+140
-0
lines changed

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

Lines changed: 65 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,25 @@ 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+
# cgroup cpus quotas have no limits, so they can be set to higher than the
32+
# real count of cores.
33+
if quota > cpu_count
34+
cpu_count
35+
else
36+
quota
37+
end
38+
end
39+
40+
def cpu_quota
41+
@cpu_quota.value
42+
end
43+
2444
private
2545

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

@@ -107,4 +145,31 @@ def self.processor_count
107145
def self.physical_processor_count
108146
processor_counter.physical_processor_count
109147
end
148+
149+
# Number of processors cores available for process scheduling.
150+
# Returns `nil` if there is no #cpu_quota, or a `Float` if the
151+
# process is inside a cgroup with a dedicated CPU quota (typically Docker).
152+
#
153+
# For performance reasons the calculated value will be memoized on the first
154+
# call.
155+
#
156+
# @return [nil, Float] number of available processors seen by the OS or Java runtime
157+
def self.available_processor_count
158+
processor_counter.available_processor_count
159+
end
160+
161+
# The maximun number of processors cores available for process scheduling.
162+
# Returns `nil` if there is no enforced limit, or a `Float` if the
163+
# process is inside a cgroup with a dedicated CPU quota (typically Docker).
164+
#
165+
# Note that nothing prevent to set a CPU quota higher than the actual number of
166+
# cores on the system.
167+
#
168+
# For performance reasons the calculated value will be memoized on the first
169+
# call.
170+
#
171+
# @return [nil, Float] Maximum number of available processors seen by the OS or Java runtime
172+
def self.cpu_quota
173+
processor_counter.cpu_quota
174+
end
110175
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)