2019-05-03
In Go, unsafe type conversions are still garbage collection safe
I was recently reading to slice or not to slice (via), where one of the
example ways of going from a slice to an array is the non copying
brute force approach with unsafe
.
To quote the first part of the example code:
bufarrayptr := (*[32]byte)(unsafe.Pointer(&buf[0])) // *[32]byte (same memory region)
(Here buf
is a slice, and we'll assume that we've already verified
that it has a len()
of at least 32.)
One of the things you might wonder about here is whether this is
safe from garbage collection. After all, we're eventually discarding
buf
, the original and true reference to the slice and its backing
array. Will the Go garbage collector someday free the memory involved
even though we still have a reference to it in the form of
bufarrayptr
? On the one hand, we do have a reference to the backing
array; on the other hand, we created the reference through the use
of unsafe
and thus went behind Go's back to do it.
(This would be an analog of the C mistake of retaining a pointer to
something that you've free()
'd.)
Conveniently, the answer is that this unsafe type conversion is still safe from being garbage collected. As I discussed in Exploring how and why interior pointers keep entire objects alive, the Go garbage collector is blind to the type of pointers; it intrinsically knows what a particular block of memory is, and any pointer to it of any type is as good as any other pointer. It does not have to be the original type, and in fact it can generally be a completely incorrect type. As far as garbage collection goes, I suspect that you can get away with a pointer of a type that is larger than what you're actually pointing to.
(The Go language specification does not say that this has to work,
though, although it does say a bit about unsafe
. The unsafe
package
itself implicitly says that reinterpreting to a too-large type is
invalid usage.)
There is an important consequence of this type blindness in the garbage collector if you are doing type conversions, and that is what is and isn't a pointer is set permanently from the original type. When the Go runtime allocates memory initially, it records the 'shape' of that memory, including what portions of it are pointers. Regardless of how you reinterpret the memory, that original shape sticks to it, and that original shape is what the garbage collector uses when determining what pointers to follow to find more used memory and what bytes to not look at further because they're not pointers. If you put non-pointer data in what the garbage collector thinks is a pointer, it will probably panic your program. If you put pointer data in what the garbage collector thinks is not a pointer, the garbage collector may decide that some memory is unused and free it even though you think you have a pointer to it; when you use that pointer later, you will be sad.
(In general, reinterpreting non-pointer memory as a pointer is not necessarily safe. The Go runtime does some things when you tell it you're modifying a pointer, and it makes no guarantees that those things will be safe if the actual memory did not start out its life as a pointer.)
PS: I think that this garbage collection safe behavior of unsafe.Pointer
is implicitly guaranteed by the first unsafe.Pointer usage pattern. This isn't part of the
language specification itself but it is part of the current unsafe
package specification, so it's pretty close. As a practical matter,
I think that the Go authors see this sort of usage as valid and
thus are likely to support it for as long as possible.
Some implications of using offset
instead of delta()
in Prometheus
I previously wrote about how delta()
can be inferior to subtraction
with offset
, because delta()
has to
load the entire range of metric points and offset
doesn't. In
light of the issue I ran into recently with stale metrics and
range queries, there turn out to
be some implications and complexities of using offset
in place
of delta()
, even if it lets you make queries that you couldn't
otherwise do.
Let's start with the basics, which is that 'delta(mymetric[30d])
'
can theoretically be replaced with 'mymetric - mymetric offset 30d
'
to get the same result with far fewer metric points having to be
loaded by Prometheus. This is an important issue for us, because
we have some high-cardinality metrics that it turns out we want
to query over long time scales like 30 or 90 days.
The first issue with the offset
replacement is what happens when
a particular set of labels for the metric didn't exist 30 days ago.
Just like PromQL boolean operators (cf),
PromQL math operators on vectors are filters, so you'll ignore all
current metric points for mymetric
that didn't exist 30 days ago.
The fix for this is the inverse of ignoring stale metrics:
(mymetric - mymetric offset 30d) or mymetric
Here, if mymetric
didn't exist 30 days ago we implicitly take its
starting value as 0 and just consider the delta to be the current
value of mymetric
. Under some circumstances you may want a different
delta value for 'new' metrics, which will require a different
computation.
The inverse of the situation is metric labels that existed 30 days
ago but don't exist now. As we saw in an earlier entry, the range query in the delta()
version will include those metrics, so they will flow through to
the delta()
calculation and be included in your final result set.
Although the delta()
documentation
sort of claims otherwise, the actual code implementing delta()
reasonably doesn't currently extrapolate samples that start and end
significantly far away from the full time range, so the delta()
result will probably be just the change over the time series points
available. In some cases this will go to zero, but in others it
will be uninteresting and you would rather pretend that the time
series is now 0. Unfortunately, as far as I know there's no good
way to do that.
If you only care about time series (ie label sets) that existed at the start of the time period, I think you can extend the previous case to:
((mymetric - mymetric offset 30d) or mymetric) or -(mymetric offset 30d)
(As before, this assumes that a time series that disappears is implicitly going to zero.)
If you care about time series that existed in the middle of the
time range but not at either the beginning or the end, I think
you're out of luck. The only way to sweep those up is a range query
and using delta()
, which runs the risk of a 'too many metric
points loaded' error.
Unfortunately all of this is increasingly verbose, especially if
you're using label matches restricting mymetric
to only some
values (because then you need to propagate these label restrictions
into at least the or
clauses). It's a pity that PromQL doesn't
have any function to do this for us.
I also have to modify something I said in my first entry on
offset
and delta()
. Given all of these
issues with appearing and disappearing time series, it's clear that
optimizing delta()
to not require the entire range is not as
simple as it looks. It would probably require some deep hooks into
the storage engine to say 'we don't need all the points, just the
start and the end points and their timestamps', and that stuff would
only be useful for gauges (since counters already have to load the
entire range set and sweep over it looking for counter resets).
In our current usage we care more about how the current metrics got
there than what the situation was in the past; we are essentially
looking backward to ask what disk space usage grew or shrank. If
some past usage went to zero and disappeared, it's okay to exclude
it entirely. There are some potentially tricky cases that might
cause me to rethink that someday, but for now I'm going to use the
shorter version that only has one or
, partly because Grafana makes
it a relatively large pain to write complicated PromQL queries.