diff --git a/lib/sys/cpu.rb b/lib/sys/cpu.rb index 83201d3..433b215 100644 --- a/lib/sys/cpu.rb +++ b/lib/sys/cpu.rb @@ -10,7 +10,7 @@ module Sys # This class is reopened for each of the supported platforms/operating systems. class CPU # The version of the sys-cpu gem. - VERSION = '1.2.0' + VERSION = '1.3.0' private_class_method :new end diff --git a/lib/sys/darwin/sys/cpu.rb b/lib/sys/darwin/sys/cpu.rb index ca34dc6..b91a32a 100644 --- a/lib/sys/darwin/sys/cpu.rb +++ b/lib/sys/darwin/sys/cpu.rb @@ -208,5 +208,119 @@ def self.load_avg loadavg.get_array_of_double(0, 3) end + + # Returns CPU usage as a percentage. + # + # If +sample_time+ is positive, samples CPU times and calculates an average + # over that interval. You can also specify +samples+ to average multiple + # consecutive measurements. + # + # If +sample_time+ is 0 (default), uses a 1-second sample window by default. + # Default value for +samples+ is 2 (averages two measurements). + # + HOST_CPU_LOAD_INFO = 3 + HOST_CPU_LOAD_INFO_COUNT = 4 + + private_constant :HOST_CPU_LOAD_INFO, :HOST_CPU_LOAD_INFO_COUNT + + attach_function :mach_host_self, [], :uint + attach_function :host_statistics, %i[uint int pointer pointer], :int + + private_class_method :mach_host_self, :host_statistics + + # Returns the current CPU usage as a percentage, averaged over a sampling interval. + # + # By default, this method samples CPU usage over a 1-second interval and averages two measurements. + # You can customize the interval and number of samples by passing the +sample_time+ (in seconds) + # and +samples+ keyword arguments. For example, +cpu_usage(sample_time: 0.5, samples: 4)+ will take four + # samples, each 0.5 seconds apart, and return the average CPU usage over that period. + # + # Passing nil, 0, or a negative value for either argument falls back to the defaults (1.0 seconds + # and 2 samples) to keep behavior consistent across platforms. + # + # Returns a Float (percentage), rounded to one decimal place, or nil if CPU usage cannot be determined. + # + # Example usage: + # Sys::CPU.cpu_usage #=> 12.3 + # Sys::CPU.cpu_usage(sample_time: 2, samples: 3) #=> 10.7 + # Sys::CPU.cpu_usage(sample_time: 0, samples: 0) #=> 12.3 # zeros fall back to defaults + # + def self.cpu_usage(sample_time: 1.0, samples: 2) + sample_time = 1.0 if sample_time.nil? || sample_time <= 0 + samples = 2 if samples.nil? || samples <= 0 + + usages = [] + + samples.times do + t1 = current_ticks + sleep(sample_time) + t2 = current_ticks + next unless t1 && t2 + + if (u = usage_between_ticks(t1, t2)) + usages << u + end + end + + return nil if usages.empty? + + (usages.sum / usages.size.to_f).round(1) + rescue StandardError + nil + end + + def self.current_ticks + cpu_ticks_sysctl || cpu_ticks_host + end + + private_class_method :current_ticks + + def self.usage_between_ticks(t1, t2) + diff = t2.map.with_index { |v, i| v - t1[i] } + total = diff.sum + return nil if total <= 0 + + # host_statistics returns [user, system, idle, nice] + idle = diff[2] || 0 + (1.0 - (idle.to_f / total)) * 100 + end + + private_class_method :usage_between_ticks + + def self.cpu_ticks_sysctl + cp_time = proc { |ptr| + len = 5 + size = FFI::MemoryPointer.new(:size_t) + size.write_ulong(ptr.size) + + if sysctlbyname('kern.cp_time', ptr, size, nil, 0) < 0 + raise Error, 'sysctlbyname failed' + end + + ptr.read_array_of_ulong(len) + } + + cp_time.call(FFI::MemoryPointer.new(:ulong, 5)) + rescue StandardError + nil + end + + private_class_method :cpu_ticks_sysctl + + def self.cpu_ticks_host + host = mach_host_self + info = FFI::MemoryPointer.new(:uint, HOST_CPU_LOAD_INFO_COUNT) + count = FFI::MemoryPointer.new(:uint) + count.write_uint(HOST_CPU_LOAD_INFO_COUNT) + + kr = host_statistics(host, HOST_CPU_LOAD_INFO, info, count) + return nil unless kr == 0 + + info.read_array_of_uint(HOST_CPU_LOAD_INFO_COUNT) + rescue StandardError + nil + end + + private_class_method :cpu_ticks_host end end diff --git a/lib/sys/linux/sys/cpu.rb b/lib/sys/linux/sys/cpu.rb index 8472610..afd3532 100644 --- a/lib/sys/linux/sys/cpu.rb +++ b/lib/sys/linux/sys/cpu.rb @@ -100,8 +100,15 @@ def self.architecture # Returns a string indicating the CPU model. # + # Some systems may use slightly different keys in /proc/cpuinfo, so + # we fall back to other common names and ensure we always return a + # String. def self.model - CPU_ARRAY.first['model_name'] + CPU_ARRAY.first['model_name'] || + CPU_ARRAY.first['model'] || + CPU_ARRAY.first['cpu'] || + CPU_ARRAY.first['processor'] || + ''.dup end # Returns an integer indicating the speed of the CPU. @@ -110,6 +117,62 @@ def self.freq CPU_ARRAY.first['cpu_mhz'].to_f.round end + # Returns the current CPU usage as a percentage, averaged over a sampling interval. + # + # By default, this method samples CPU usage over a 1-second interval and averages two measurements. + # You can customize the interval and number of samples by passing the +sample_time+ (in seconds) + # and +samples+ keyword arguments. For example, +cpu_usage(sample_time: 0.5, samples: 4)+ will take four + # samples, each 0.5 seconds apart, + # and return the average CPU usage over that period. + # + # Passing nil, 0, or a negative value for either argument falls back to the defaults (1.0 seconds and + # 2 samples) for cross-platform consistency. + # + # Returns a Float (percentage), rounded to one decimal place, or nil if CPU usage cannot be determined. + # + # Example usage: + # Sys::CPU.cpu_usage #=> 12.3 + # Sys::CPU.cpu_usage(sample_time: 2, samples: 3) #=> 10.7 + # Sys::CPU.cpu_usage(sample_time: 0, samples: 0) #=> 12.3 # zeros fall back to defaults + # + def self.cpu_usage(sample_time: 1.0, samples: 2) + sample_time = 1.0 if sample_time.nil? || sample_time <= 0 + samples = 2 if samples.nil? || samples <= 0 + + usages = [] + + samples.times do + stats1 = cpu_stats + sleep(sample_time) + stats2 = cpu_stats + + total_diff = 0.0 + idle_diff = 0.0 + + keys = stats1.key?('cpu') ? ['cpu'] : stats1.keys + keys.each do |key| + arr1 = stats1[key] + arr2 = stats2[key] + next unless arr1 && arr2 + t1 = arr1.sum + t2 = arr2.sum + total = t2 - t1 + idle = (arr2[3] || 0) - (arr1[3] || 0) + total_diff += total + idle_diff += idle + end + + if total_diff > 0 + usages << ((1.0 - (idle_diff / total_diff)) * 100) + end + end + + return nil if usages.empty? + (usages.sum / usages.size.to_f).round(1) + rescue StandardError + nil + end + # Create singleton methods for each of the attributes. # def self.method_missing(id, arg = 0) @@ -168,7 +231,9 @@ def self.cpu_stats next end - vals = array[1..-1].map{ |e| e.to_i / 100 } # 100 jiffies/sec. + # Keep raw jiffies counts (do not scale by hz) so deltas over short + # intervals still produce meaningful values. + vals = array[1..-1].map{ |e| e.to_i } hash[array[0]] = vals end diff --git a/lib/sys/unix/sys/cpu.rb b/lib/sys/unix/sys/cpu.rb index 87de592..88d9bcb 100644 --- a/lib/sys/unix/sys/cpu.rb +++ b/lib/sys/unix/sys/cpu.rb @@ -309,6 +309,52 @@ def self.load_avg loadavg.get_array_of_double(0, 3) end + # Returns CPU usage as a percentage, averaged over a sampling interval. + # + # By default, samples CPU times twice, 1 second apart. Arguments are keyword-based + # (+sample_time:+, +samples:+). Passing nil, 0, or a negative value for either + # falls back to these defaults for cross-platform consistency. + # + def self.cpu_usage(sample_time: 1.0, samples: 2) + cp_time = proc { |ptr| + len = 5 + size = FFI::MemoryPointer.new(:size_t) + size.write_ulong(ptr.size) + + if sysctlbyname('kern.cp_time', ptr, size, nil, 0) < 0 + raise Error, 'sysctlbyname failed' + end + + ptr.read_array_of_ulong(len) + } + + sample_time = 1.0 if sample_time.nil? || sample_time <= 0 + samples = 2 if samples.nil? || samples <= 0 + + usages = [] + + samples.times do + t1 = cp_time.call(FFI::MemoryPointer.new(:ulong, 5)) + sleep(sample_time) + t2 = cp_time.call(FFI::MemoryPointer.new(:ulong, 5)) + + total1 = t1.sum + total2 = t2.sum + idle1 = t1[4] || 0 + idle2 = t2[4] || 0 + + total_diff = total2 - total1 + idle_diff = idle2 - idle1 + + usages << ((1.0 - (idle_diff.to_f / total_diff)) * 100) if total_diff > 0 + end + + return nil if usages.empty? + (usages.sum / usages.size.to_f).round(1) + rescue StandardError + nil + end + # Returns the floating point processor type. # # Not supported on all platforms. diff --git a/lib/sys/windows/sys/cpu.rb b/lib/sys/windows/sys/cpu.rb index cdc8584..0e86ff7 100644 --- a/lib/sys/windows/sys/cpu.rb +++ b/lib/sys/windows/sys/cpu.rb @@ -117,6 +117,46 @@ def self.load_avg(cpu_num = 0, host = Socket.gethostname) end end + # Returns CPU usage as a percentage, averaged over multiple samples. + # + # The +sample_time+ keyword specifies the interval (in seconds) between samples. + # The +samples+ keyword specifies how many samples to take and average. + # The +cpu_num+ keyword selects which CPU to query (0 for total). + # The +host+ keyword specifies the target machine (defaults to local). + # + #-- + # This method uses the _Total Win32_PerfFormattedData_PerfOS_Processor instance + # (unless a specific +cpu_num+ is requested) to better match Task Manager's total view. + # + # Note: Task Manager reports total CPU usage across all cores. Win32_Processor.LoadPercentage + # is per-processor (usually per physical socket), so it can differ from Task Manager if it falls back. + # + def self.cpu_usage(sample_time: 1.0, samples: 2, cpu_num: 0, host: Socket.gethostname) + sample_time = 1.0 if sample_time.nil? || sample_time <= 0 + samples = 2 if samples.nil? || samples <= 0 + cpu_num = cpu_num.to_i if cpu_num.respond_to?(:to_i) + instance = cpu_num == 0 ? '_Total' : cpu_num.to_s + cs = BASE_CS + "//#{host}/root/cimv2:Win32_PerfFormattedData_PerfOS_Processor='#{instance}'" + + usages = [] + + samples.times do + begin + wmi = WIN32OLE.connect(cs) + rescue WIN32OLERuntimeError + usages << load_avg(cpu_num, host) + else + result = wmi.PercentProcessorTime + usages << result.to_i if result + end + sleep(sample_time) + end + + usages.compact! + return nil if usages.empty? + (usages.sum / usages.size.to_f).round(1) + end + # Returns a string indicating the cpu model, e.g. Intel Pentium 4. # def self.model(host = Socket.gethostname) diff --git a/spec/sys_cpu_bsd_spec.rb b/spec/sys_cpu_bsd_spec.rb index de0738f..e44df77 100644 --- a/spec/sys_cpu_bsd_spec.rb +++ b/spec/sys_cpu_bsd_spec.rb @@ -52,6 +52,26 @@ expect{ described_class.load_avg(0) }.to raise_error(ArgumentError) end + example 'cpu_usage works as expected' do + expect(described_class).to respond_to(:cpu_usage) + expect{ described_class.cpu_usage }.not_to raise_error + expect{ described_class.cpu_usage(sample_time: 0.1) }.not_to raise_error + expect(described_class.cpu_usage).to be_a(Numeric).or be_nil + end + + example 'cpu_usage falls back on non-positive values' do + expect{ described_class.cpu_usage(sample_time: 0, samples: 0) }.not_to raise_error + expect{ described_class.cpu_usage(sample_time: -0.5, samples: -1) }.not_to raise_error + expect(described_class.cpu_usage(sample_time: 0, samples: 0)).to be_a(Numeric).or be_nil + end + + example 'cpu_usage sampling produces a valid range' do + result = described_class.cpu_usage(sample_time: 0.1) + expect(result).to be_a(Numeric).or be_nil + expect(result).to be >= 0 if result + expect(result).to be <= 100 if result + end + example 'machine method basic functionality' do expect(described_class).to respond_to(:machine) expect{ described_class.machine }.not_to raise_error diff --git a/spec/sys_cpu_hpux_spec.rb b/spec/sys_cpu_hpux_spec.rb index 3ef05a9..59dba8f 100644 --- a/spec/sys_cpu_hpux_spec.rb +++ b/spec/sys_cpu_hpux_spec.rb @@ -52,4 +52,24 @@ expect(described_class.load_avg.length).to eq(3) expect(described_class.load_avg(0).length).to eq(3) end + + example 'cpu_usage works as expected' do + expect(described_class).to respond_to(:cpu_usage) + expect{ described_class.cpu_usage }.not_to raise_error + expect{ described_class.cpu_usage(sample_time: 0.1) }.not_to raise_error + expect(described_class.cpu_usage).to be_a(Numeric).or be_nil + end + + example 'cpu_usage falls back on non-positive values' do + expect{ described_class.cpu_usage(sample_time: 0, samples: 0) }.not_to raise_error + expect{ described_class.cpu_usage(sample_time: -1, samples: -1) }.not_to raise_error + expect(described_class.cpu_usage(sample_time: 0, samples: 0)).to be_a(Numeric).or be_nil + end + + example 'cpu_usage sampling produces a valid range' do + result = described_class.cpu_usage(sample_time: 0.1) + expect(result).to be_a(Numeric).or be_nil + expect(result).to be >= 0 if result + expect(result).to be <= 100 if result + end end diff --git a/spec/sys_cpu_linux_spec.rb b/spec/sys_cpu_linux_spec.rb index c30f986..61b504f 100644 --- a/spec/sys_cpu_linux_spec.rb +++ b/spec/sys_cpu_linux_spec.rb @@ -49,6 +49,25 @@ expect(described_class.num_cpu).to be_a(Numeric) end + example 'cpu_usage works as expected' do + expect{ described_class.cpu_usage }.not_to raise_error + expect(described_class.cpu_usage).to be_a(Numeric) + end + + example 'cpu_usage falls back on non-positive values' do + expect{ described_class.cpu_usage(sample_time: 0, samples: 0) }.not_to raise_error + expect{ described_class.cpu_usage(sample_time: -1, samples: -2) }.not_to raise_error + expect(described_class.cpu_usage(sample_time: 0, samples: 0)).to be_a(Numeric) + end + + example 'cpu_usage sampling produces a valid range' do + # Sampled usage should be a number between 0 and 100. + result = described_class.cpu_usage(sample_time: 0.1) + expect(result).to be_a(Numeric) + expect(result).to be >= 0 + expect(result).to be <= 100 + end + example 'bogus methods are not picked up by method_missing' do expect{ described_class.bogus }.to raise_error(NoMethodError) end diff --git a/spec/sys_cpu_shared.rb b/spec/sys_cpu_shared.rb index e3e5cca..ae3e38a 100644 --- a/spec/sys_cpu_shared.rb +++ b/spec/sys_cpu_shared.rb @@ -11,7 +11,7 @@ RSpec.shared_examples Sys::CPU do example 'version number is set to the expected value' do - expect(Sys::CPU::VERSION).to eq('1.2.0') + expect(Sys::CPU::VERSION).to eq('1.3.0') end example 'version number is frozen' do diff --git a/spec/sys_cpu_windows_spec.rb b/spec/sys_cpu_windows_spec.rb index 109704d..923c904 100644 --- a/spec/sys_cpu_windows_spec.rb +++ b/spec/sys_cpu_windows_spec.rb @@ -59,6 +59,27 @@ expect(described_class.load_avg).to be_a(Integer).or be_a(NilClass) end + example 'cpu_usage works as expected' do + expect(described_class).to respond_to(:cpu_usage) + expect{ described_class.cpu_usage }.not_to raise_error + expect{ described_class.cpu_usage(sample_time: 0.1, samples: 0, host: host) }.not_to raise_error + expect(described_class.cpu_usage).to be_a(Numeric).or be_a(NilClass) + end + + example 'cpu_usage falls back on non-positive values' do + expect{ described_class.cpu_usage(sample_time: 0, samples: 0, host: host) }.not_to raise_error + expect{ described_class.cpu_usage(sample_time: -1, samples: -1, host: host) }.not_to raise_error + expect(described_class.cpu_usage(sample_time: 0, samples: 0, host: host)).to be_a(Numeric).or be_a(NilClass) + end + + example 'cpu_usage sampling produces a valid range' do + # Sampled usage should be a number between 0 and 100. + result = described_class.cpu_usage(sample_time: 0.1) + expect(result).to be_a(Numeric).or be_nil + expect(result).to be >= 0 if result + expect(result).to be <= 100 if result + end + example 'processors' do expect(described_class).to respond_to(:processors) expect{ described_class.processors{} }.not_to raise_error diff --git a/sys-cpu.gemspec b/sys-cpu.gemspec index 7b1d4e2..e13bc94 100644 --- a/sys-cpu.gemspec +++ b/sys-cpu.gemspec @@ -2,7 +2,7 @@ require 'rubygems' Gem::Specification.new do |spec| spec.name = 'sys-cpu' - spec.version = '1.2.0' + spec.version = '1.3.0' spec.author = 'Daniel J. Berger' spec.email = 'djberg96@gmail.com' spec.license = 'Apache-2.0'