Skip to content

Commit 4ec81f2

Browse files
committed
SR-12833: processInfo.activeProcessorCount ignores CFS quotas
- For Linux, take into account CFS quotes eg, if running under Docker. - Based on swift-nio code, apple/swift-nio#1518
1 parent 7d521e4 commit 4ec81f2

File tree

3 files changed

+126
-13
lines changed

3 files changed

+126
-13
lines changed

Sources/Foundation/ProcessInfo.swift

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,16 @@ open class ProcessInfo: NSObject {
251251
internal let _processorCount: Int = Int(__CFProcessorCount())
252252
open var processorCount: Int { _processorCount }
253253

254-
internal let _activeProcessorCount: Int = Int(__CFActiveProcessorCount())
255-
open var activeProcessorCount: Int { _activeProcessorCount }
254+
255+
open var activeProcessorCount: Int {
256+
#if os(Linux)
257+
// coreCount taks into account cgroup information eg if running under Docker
258+
// __CFActiveProcessorCount uses sched_getaffinity() and sysconf(_SC_NPROCESSORS_ONLN)
259+
return ProcessInfo.coreCount() ?? Int(__CFActiveProcessorCount())
260+
#else
261+
return Int(__CFActiveProcessorCount())
262+
#endif
263+
}
256264

257265
internal let _physicalMemory = __CFMemorySize()
258266
open var physicalMemory: UInt64 {
@@ -293,6 +301,63 @@ open class ProcessInfo: NSObject {
293301
open var fullUserName: String {
294302
return NSFullUserName()
295303
}
304+
305+
306+
#if os(Linux)
307+
// Support for CFS quotas for cpu count as used by Docker.
308+
// Based on swift-nio code, https://github.com/apple/swift-nio/pull/1518
309+
private static let cfsQuotaPath = "/sys/fs/cgroup/cpu/cpu.cfs_quota_us"
310+
private static let cfsPeriodPath = "/sys/fs/cgroup/cpu/cpu.cfs_period_us"
311+
private static let cpuSetPath = "/sys/fs/cgroup/cpuset/cpuset.cpus"
312+
313+
private static func firstLineOfFile(path: String) throws -> Substring {
314+
let data = try Data(contentsOf: URL(fileURLWithPath: path))
315+
if let string = String(data: data, encoding: .utf8), let line = string.split(separator: "\n").first {
316+
return line
317+
} else {
318+
return ""
319+
}
320+
}
321+
322+
// These are internal access for testing
323+
static func countCoreIds(cores: Substring) -> Int {
324+
let ids = cores.split(separator: "-", maxSplits: 1)
325+
guard let first = ids.first.flatMap({ Int($0, radix: 10) }),
326+
let last = ids.last.flatMap({ Int($0, radix: 10) }),
327+
last >= first
328+
else { preconditionFailure("cpuset format is incorrect") }
329+
return 1 + last - first
330+
}
331+
332+
static func coreCount(cpuset cpusetPath: String) -> Int? {
333+
guard let cpuset = try? firstLineOfFile(path: cpusetPath).split(separator: ","),
334+
!cpuset.isEmpty
335+
else { return nil }
336+
337+
return cpuset.map(countCoreIds).reduce(0, +)
338+
}
339+
340+
static func coreCount(quota quotaPath: String, period periodPath: String) -> Int? {
341+
guard let quota = try? Int(firstLineOfFile(path: quotaPath)),
342+
quota > 0
343+
else { return nil }
344+
guard let period = try? Int(firstLineOfFile(path: periodPath)),
345+
period > 0
346+
else { return nil }
347+
348+
return (quota - 1 + period) / period // always round up if fractional CPU quota requested
349+
}
350+
351+
private static func coreCount() -> Int? {
352+
if let quota = coreCount(quota: cfsQuotaPath, period: cfsPeriodPath) {
353+
return quota
354+
} else if let cpusetCount = coreCount(cpuset: cpuSetPath) {
355+
return cpusetCount
356+
} else {
357+
return nil
358+
}
359+
}
360+
#endif
296361
}
297362

298363
// SPI for TestFoundation

Tests/Foundation/Tests/TestProcessInfo.swift

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,16 @@
77
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
88
//
99

10+
#if NS_FOUNDATION_ALLOWS_TESTABLE_IMPORT
11+
#if canImport(SwiftFoundation) && !DEPLOYMENT_RUNTIME_OBJC
12+
@testable import SwiftFoundation
13+
#else
14+
@testable import Foundation
15+
#endif
16+
#endif
17+
1018
class TestProcessInfo : XCTestCase {
1119

12-
static var allTests: [(String, (TestProcessInfo) -> () throws -> Void)] {
13-
return [
14-
("test_operatingSystemVersion", test_operatingSystemVersion ),
15-
("test_processName", test_processName ),
16-
("test_globallyUniqueString", test_globallyUniqueString ),
17-
("test_environment", test_environment),
18-
]
19-
}
20-
2120
func test_operatingSystemVersion() {
2221
let processInfo = ProcessInfo.processInfo
2322
let versionString = processInfo.operatingSystemVersionString
@@ -134,4 +133,51 @@ class TestProcessInfo : XCTestCase {
134133
XCTAssertEqual(env["var4"], "x=")
135134
XCTAssertEqual(env["var5"], "=x=")
136135
}
136+
137+
138+
#if NS_FOUNDATION_ALLOWS_TESTABLE_IMPORT && os(Linux)
139+
func test_cfquota_parsing() throws {
140+
141+
let tests = [
142+
("50000", "100000", 1),
143+
("100000", "100000", 1),
144+
("100000\n", "100000", 1),
145+
("100000", "100000\n", 1),
146+
("150000", "100000", 2),
147+
("200000", "100000", 2),
148+
("-1", "100000", nil),
149+
("100000", "-1", nil),
150+
("", "100000", nil),
151+
("100000", "", nil),
152+
("100000", "0", nil)
153+
]
154+
155+
try withTemporaryDirectory() { (_, tempDirPath) -> Void in
156+
try tests.forEach { quota, period, count in
157+
let (fd1, quotaPath) = try _NSCreateTemporaryFile(tempDirPath + "/quota")
158+
FileHandle(fileDescriptor: fd1, closeOnDealloc: true).write(quota)
159+
160+
let (fd2, periodPath) = try _NSCreateTemporaryFile(tempDirPath + "/period")
161+
FileHandle(fileDescriptor: fd2, closeOnDealloc: true).write(period)
162+
XCTAssertEqual(ProcessInfo.coreCount(quota: quotaPath, period: periodPath), count)
163+
}
164+
}
165+
}
166+
#endif
167+
168+
169+
static var allTests: [(String, (TestProcessInfo) -> () throws -> Void)] {
170+
var tests: [(String, (TestProcessInfo) -> () throws -> ())] = [
171+
("test_operatingSystemVersion", test_operatingSystemVersion ),
172+
("test_processName", test_processName ),
173+
("test_globallyUniqueString", test_globallyUniqueString ),
174+
("test_environment", test_environment),
175+
]
176+
177+
#if NS_FOUNDATION_ALLOWS_TESTABLE_IMPORT && os(Linux)
178+
tests.append(contentsOf: [ ("test_cfquota_parsing", test_cfquota_parsing) ])
179+
#endif
180+
181+
return tests
182+
}
137183
}

Tests/Foundation/Utilities.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -667,8 +667,10 @@ public func withTemporaryDirectory<R>(functionName: String = #function, block: (
667667
throw TestError.unexpectedNil
668668
}
669669

670-
let fname = String(functionName[..<idx])
671-
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(testBundleName()).appendingPathComponent(fname).appendingPathComponent(NSUUID().uuidString)
670+
// Create the temporary directory as one level so that it doesnt leave a directory hierarchy on the filesystem
671+
// eg tmp dir will be something like: /tmp/TestFoundation-test_name-BE16B2FF-37FA-4F70-8A84-923D1CC2A860
672+
let fname = testBundleName() + "-" + String(functionName[..<idx]) + "-" + NSUUID().uuidString
673+
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(fname)
672674
let fm = FileManager.default
673675
try? fm.removeItem(at: tmpDir)
674676
try fm.createDirectory(at: tmpDir, withIntermediateDirectories: true)

0 commit comments

Comments
 (0)