tl;dr
When logging large numbers of intervals in “Points of Interest” tool, I am seeing intervals randomly getting omitted from the graph in the main pane. Are others seeing this problem? Am I doing something wrong?
I use Xcode Instruments pretty extensively to monitor asynchronous work with “Points of Interest” intervals. See How to identify key events in Xcode Instruments?
(For the record, there are other tools, such as those outlined in WWDC 2022’s Visualize and optimize Swift concurrency, but for me, intervals are still my “go to” tool.)
Anyway, I can create a points of interest OSSignposter
(previously, OSLog
):
import os.log
let poi = OSSignposter(subsystem: "…", category: .pointsOfInterest)
And then I can do things like:
let id = poi.makeSignpostID()
let state = poi.beginInterval(#function, id: id, "\(value)")
…
poi.endInterval(#function, state)
I can then profile the app in Instruments (from Xcode’s “Product” » “Profile” or command-i), start with the “Time profiling” template, and I get a nice timeline of the execution of my app. (You can also do things like right-click on an interval and set the inspection range, thereby narrowing your investigation of CPU or RAM or whatever, to some well-defined interval of code execution.)
So, for example, I ran foo
, then bar
, and finally baz
, each logging their intervals and yielding the following in Instruments:
That’s all fine. (I kind of wish this graph was in chronological order, not alphabetical order. I also occasionally see frameworks logging to Points of Interest, which is exceedingly distracting when trying to diagnose issues in my own code. But neither of these are relevant to my question here.)
But recently, I have increasingly seen a loss of intervals in the graph. For example, here I ran 50 iterations, each with four concurrent tasks, with a pause in between each iteration, yielding the following:
But notice that at iterations 2, 16, 20, 40 and 46, there are missing intervals in the graph. These intervals are omitted somewhat randomly, anywhere between 5-15% of the time. Down in the lower “Detail” panel, I can see that all the intervals are actually captured by Instruments successfully, but some simply are not represented in the visual graph in the main pane. (And, only by doing large intervals did I finally notice a correlation between a missing interval and the fact that Instruments stopped using that lane and created a new lane.)
Some things I’ve tested include:
Task.sleep
).Have others experienced this behavior? What is going on?
For those who are interested, this is the code that generated the above screen snapshot in Instruments:
import SwiftUI
import os.log
let poi = OSSignposter(subsystem: "Test", category: .pointsOfInterest)
struct ContentView: View {
@State var status = "Not started…"
@State var buttonsDisabled = false
var body: some View {
VStack {
Text("Instruments’ “Points of Interest” experiment")
Text(status)
Button("Test Async-Await 100 Times") {
Task {
status = "Starting"
buttonsDisabled = true
for iteration in 0 ..< 100 {
try await fourConcurrentTasks(for: iteration)
status = "Finished \(iteration) … still running"
try await Task.sleep(for: .seconds(0.25))
}
status = "All done"
buttonsDisabled = false
}
}
.disabled(buttonsDisabled)
}
.padding()
}
func fourConcurrentTasks(for iteration: Int) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
for index in 0..<4 {
group.addTask {
let id = poi.makeSignpostID()
let state = poi.beginInterval(#function, id: id, "\(iteration): \(index)")
try await Task.sleep(for: .seconds(0.25))
poi.endInterval(#function, state)
}
}
try await group.waitForAll()
}
}
}
Prior to iOS 18 and macOS 15, OSSignposter
was not thread-safe. The docs did not make any reference to thread-safety, one way or the other. It was not Sendable
, either (the standard convention nowadays for a class to inform us that it is thread-safe). And if you use it in a Swift concurrency codebase from a non-actor-isolated context, the “Strict Concurrency Checking” build setting of “Complete” will often warn you of this misuse.
As a general rule, a type can only be considered thread-safe if the documentation makes assurances to that end (or, obviously, it is marked Sendable
).
I confirmed this problem manifests itself only when intervals were started in parallel and at the same time. I also notice that when I added synchronization round the OSSignposter
and its API, the problem disappeared.
But, as of iOS 18 and macOS 15, OSSignposter
(and Logger
) are now Sendable
, and no longer manifest this behavior.
I must confess that I originally assumed that this was merely a bug in the “Points of Interest” tool because:
The details pane in Instruments reported all the intervals correctly.
My own primitive custom Instruments tool (that used the same signpost schema, os-signpost-point-schema
that the standard “Points of Interest” tool uses) does not manifest the problem.
But in retrospect, this is classic “race condition” sort of behavior. And given the lack of any assurances regarding thread-safety, that makes perfect sense.