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



require 'etc'
require 'rbconfig'
require 'concurrent/delay'

module Concurrent
  # @!visibility private
  module Utility

    # @!visibility private
    class ProcessorCounter
      def initialize
        @processor_count          = Delay.new { compute_processor_count }
        @physical_processor_count = Delay.new { compute_physical_processor_count }
        @cpu_quota                = Delay.new { compute_cpu_quota }
        @cpu_shares               = Delay.new { compute_cpu_shares }
      end

      def processor_count
        @processor_count.value
      end

      def physical_processor_count
        @physical_processor_count.value
      end

      def available_processor_count
        cpu_count = processor_count.to_f
        quota = cpu_quota

        return cpu_count if quota.nil?

        # cgroup cpus quotas have no limits, so they can be set to higher than the
        # real count of cores.
        if quota > cpu_count
          cpu_count
        else
          quota
        end
      end

      def cpu_quota
        @cpu_quota.value
      end

      def cpu_shares
        @cpu_shares.value
      end

      private

      def compute_processor_count
        if Concurrent.on_jruby?
          java.lang.Runtime.getRuntime.availableProcessors
        else
          Etc.nprocessors
        end
      end

      def compute_physical_processor_count
        ppc = case RbConfig::CONFIG["target_os"]
              when /darwin\d\d/
                IO.popen("/usr/sbin/sysctl -n hw.physicalcpu", &:read).to_i
              when /linux/
                cores = {} # unique physical ID / core ID combinations
                phy   = 0
                IO.read("/proc/cpuinfo").scan(/^physical id.*|^core id.*/) do |ln|
                  if ln.start_with?("physical")
                    phy = ln[/\d+/]
                  elsif ln.start_with?("core")
                    cid        = phy + ":" + ln[/\d+/]
                    cores[cid] = true if not cores[cid]
                  end
                end
                cores.count
              when /mswin|mingw/
                # Get-CimInstance introduced in PowerShell 3 or earlier: https://learn.microsoft.com/en-us/previous-versions/powershell/module/cimcmdlets/get-ciminstance?view=powershell-3.0
                result = run('powershell -command "Get-CimInstance -ClassName Win32_Processor -Property NumberOfCores | Select-Object -Property NumberOfCores"')
                if !result || $?.exitstatus != 0
                  # fallback to deprecated wmic for older systems
                  result = run("wmic cpu get NumberOfCores")
                end
                if !result || $?.exitstatus != 0
                  # Bail out if both commands returned something unexpected
                  processor_count
                else
                  # powershell: "\nNumberOfCores\n-------------\n            4\n\n\n"
                  # wmic:       "NumberOfCores  \n\n4              \n\n\n\n"
                  result.scan(/\d+/).map(&:to_i).reduce(:+)
                end
              else
                processor_count
              end
        # fall back to logical count if physical info is invalid
        ppc > 0 ? ppc : processor_count
      rescue
        return 1
      end

      def run(command)
        IO.popen(command, &:read)
      rescue Errno::ENOENT
      end

      def compute_cpu_quota
        if RbConfig::CONFIG["target_os"].include?("linux")
          if File.exist?("/sys/fs/cgroup/cpu.max")
            # cgroups v2: https://docs.kernel.org/admin-guide/cgroup-v2.html#cpu-interface-files
            cpu_max = File.read("/sys/fs/cgroup/cpu.max")
            return nil if cpu_max.start_with?("max ") # no limit
            max, period = cpu_max.split.map(&:to_f)
            max / period
          elsif File.exist?("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us")
            # cgroups v1: https://kernel.googlesource.com/pub/scm/linux/kernel/git/glommer/memcg/+/cpu_stat/Documentation/cgroups/cpu.txt
            max = File.read("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us").to_i
            # If the cpu.cfs_quota_us is -1, cgroup does not adhere to any CPU time restrictions
            # https://docs.kernel.org/scheduler/sched-bwc.html#management
            return nil if max <= 0
            period = File.read("/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us").to_f
            max / period
          end
        end
      end

      def compute_cpu_shares
        if RbConfig::CONFIG["target_os"].include?("linux")
          if File.exist?("/sys/fs/cgroup/cpu.weight")
            # cgroups v2: https://docs.kernel.org/admin-guide/cgroup-v2.html#cpu-interface-files
            # Ref: https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/2254-cgroup-v2#phase-1-convert-from-cgroups-v1-settings-to-v2
            weight = File.read("/sys/fs/cgroup/cpu.weight").to_f
            ((((weight - 1) * 262142) / 9999) + 2) / 1024
          elsif File.exist?("/sys/fs/cgroup/cpu/cpu.shares")
            # cgroups v1: https://kernel.googlesource.com/pub/scm/linux/kernel/git/glommer/memcg/+/cpu_stat/Documentation/cgroups/cpu.txt
            File.read("/sys/fs/cgroup/cpu/cpu.shares").to_f / 1024
          end
        end
      end
    end
  end

  # create the default ProcessorCounter on load
  @processor_counter = Utility::ProcessorCounter.new
  singleton_class.send :attr_reader, :processor_counter

  # Number of processors seen by the OS and used for process scheduling. For
  # performance reasons the calculated value will be memoized on the first
  # call.
  #
  # When running under JRuby the Java runtime call
  # `java.lang.Runtime.getRuntime.availableProcessors` will be used. According
  # to the Java documentation this "value may change during a particular
  # invocation of the virtual machine... [applications] should therefore
  # occasionally poll this property." We still memoize this value once under
  # JRuby.
  #
  # Otherwise Ruby's Etc.nprocessors will be used.
  #
  # @return [Integer] number of processors seen by the OS or Java runtime
  #
  # @see http://docs.oracle.com/javase/6/docs/api/java/lang/Runtime.html#availableProcessors()
  def self.processor_count
    processor_counter.processor_count
  end

  # Number of physical processor cores on the current system. For performance
  # reasons the calculated value will be memoized on the first call.
  #
  # On Windows the Win32 API will be queried for the `NumberOfCores from
  # Win32_Processor`. This will return the total number "of cores for the
  # current instance of the processor." On Unix-like operating systems either
  # the `hwprefs` or `sysctl` utility will be called in a subshell and the
  # returned value will be used. In the rare case where none of these methods
  # work or an exception is raised the function will simply return 1.
  #
  # @return [Integer] number physical processor cores on the current system
  #
  # @see https://github.com/grosser/parallel/blob/4fc8b89d08c7091fe0419ca8fba1ec3ce5a8d185/lib/parallel.rb
  #
  # @see http://msdn.microsoft.com/en-us/library/aa394373(v=vs.85).aspx
  # @see http://www.unix.com/man-page/osx/1/HWPREFS/
  # @see http://linux.die.net/man/8/sysctl
  def self.physical_processor_count
    processor_counter.physical_processor_count
  end

  # Number of processors cores available for process scheduling.
  # This method takes in account the CPU quota if the process is inside a cgroup with a
  # dedicated CPU quota (typically Docker).
  # Otherwise it returns the same value as #processor_count but as a Float.
  #
  # For performance reasons the calculated value will be memoized on the first
  # call.
  #
  # @return [Float] number of available processors
  def self.available_processor_count
    processor_counter.available_processor_count
  end

  # The maximum number of processors cores available for process scheduling.
  # Returns `nil` if there is no enforced limit, or a `Float` if the
  # process is inside a cgroup with a dedicated CPU quota (typically Docker).
  #
  # Note that nothing prevents setting a CPU quota higher than the actual number of
  # cores on the system.
  #
  # For performance reasons the calculated value will be memoized on the first
  # call.
  #
  # @return [nil, Float] Maximum number of available processors as set by a cgroup CPU quota, or nil if none set
  def self.cpu_quota
    processor_counter.cpu_quota
  end

  # The CPU shares requested by the process. For performance reasons the calculated
  # value will be memoized on the first call.
  #
  # @return [Float, nil] CPU shares requested by the process, or nil if not set
  def self.cpu_shares
    processor_counter.cpu_shares
  end
end