-
Notifications
You must be signed in to change notification settings - Fork 733
variable: return native Go pointers to bpf map memory #1607
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
1c12429
to
ea5f31d
Compare
2d43b45
to
821aa80
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can either of you refresh my mind again what the killer feature for this is? Seems like @mejedi is interested in using this for slices?
memory_unsafe.go
Outdated
} | ||
|
||
var t T | ||
size := binary.Size(t) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this binary.Size and not unsafe.Sizeof?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This also doesn't take unsafe.Alignof
into account.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this binary.Size and not unsafe.Sizeof?
unsafe.Sizeof()
tolerates variable-sized types like untyped ints and also returns the size of the slice descriptor if the argument is a slice. binary.Size()
rejects both. I think it's safer to reject variable-sized for now to avoid potential surprises.
This also doesn't take unsafe.Alignof into account.
Don't the next few lines take care of ensuring aligned access?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's safer to reject variable-sized for now to avoid potential surprises.
Unfortunately binary.Size ignores trailing padding, which means that it is not safe to use that function here.
struct foo {
uint64_t a;
uint32_t b;
// implicit 4 byte padding
}
I agree with you that these types shouldn't be allowed, but I think it might be better to do that in the caller of this function. Then we have a separation of concerns:
- this function ensures that we only transmute memory that is "safe" per the memory model, respecting full size and alignment.
- the caller can further restrict this to sized types, maybe ones that embed
structs.HostLayout
, etc.
Don't the next few lines take care of ensuring aligned access?
The alignment of a type can be smaller than its size, so I think that the check below is incorrect in the sense that it rejects correctly aligned structs. For example:
struct foo {
uint64_t a, b;
}
This must be aligned to 8 bytes, not 16.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately binary.Size ignores trailing padding, which means that it is not safe to use that function here.
I see. I guess that's the reasoning behind unsafeBackingMemory
as well; there you're using reflect.Type.Size()
, which is analogous to unsafe.Sizeof()
. Then, you compare it to binary.Size()
to detect padding.
Thanks for the feedback! I'll see what I can come up with. Looks like we also need better tests. 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should now be addressed, PTAL.
I think the most pressing use case is atomics, which is currently not supported at all. Array variables currently work through Get() and Set(), but there's always a marshaling cost. Also, simply having the convenience of manipulating BPF variables like they are Go variables is nice. |
memory_unsafe.go
Outdated
} | ||
|
||
var t T | ||
size := binary.Size(t) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's safer to reject variable-sized for now to avoid potential surprises.
Unfortunately binary.Size ignores trailing padding, which means that it is not safe to use that function here.
struct foo {
uint64_t a;
uint32_t b;
// implicit 4 byte padding
}
I agree with you that these types shouldn't be allowed, but I think it might be better to do that in the caller of this function. Then we have a separation of concerns:
- this function ensures that we only transmute memory that is "safe" per the memory model, respecting full size and alignment.
- the caller can further restrict this to sized types, maybe ones that embed
structs.HostLayout
, etc.
Don't the next few lines take care of ensuring aligned access?
The alignment of a type can be smaller than its size, so I think that the check below is incorrect in the sense that it rejects correctly aligned structs. For example:
struct foo {
uint64_t a, b;
}
This must be aligned to 8 bytes, not 16.
|
||
// Bump the value by 1 using a bpf program. | ||
want := uint32(1338) | ||
a32, err := VariablePointer[atomic.Uint32](obj.Atomic) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I should've said: it also only contains unexported fields. Passing the type to binary.Read or Write will cause an error. Is that the correct choice to make here?
Signed-off-by: Timo Beckers <[email protected]>
Signed-off-by: Timo Beckers <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for adding all those tests, they look excellent to me! Left one thought around how to enable / disable this, feel free to deal with that as you wish. I have concerns around what types should be allowed, where I'd appreciate it if we can be even more conservative for a start.
// Experimental: enabling this may cause segfaults or compromise the memory | ||
// integrity of your Go application or the bpf maps representing Variables. | ||
// Use at your own risk. | ||
UnsafeVariableExperiment bool |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I remember that you mentioned using an environment variable for this. You ended up deciding against it? I kind of came to like that idea.
Using an env variable has the nice property that the author of the application has to opt in to enabling this. It would discourage / prevent use of this feature in libraries. The downside is that applications have to os.Setenv in main.
Another way to tackle that would be using a (much hated by you) build tag. That has similar behaviour (the author of the binary gets to decide) without having to much with the environment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, didn't manage to get that done last week. Initially, I was tempted to extend the experiment to MemoryPointer()
as well, but while implementing the type checker, I realized MemoryPointer()
for arenas would be relatively useless without allowing pointers, so that needs a bit more work.
// Frozen maps can't be mapped rw either. | ||
frozen := mustMmapableArray(t, 0) | ||
qt.Assert(t, qt.IsNil(frozen.Freeze())) | ||
fz, err := frozen.Memory() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Out of curiosity: What happens when doing Memory()
and then Freeze()
? Does freezing fail?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
||
// Bump the value by 1 using a bpf program. | ||
want := uint32(1338) | ||
a32, err := VariablePointer[atomic.Uint32](obj.Atomic) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd argue yes; it would be quite hamstrung otherwise.
There are atomic free functions that you can use without relying on the atomic types.
but if someone wants to stuff a mutex in there, it feels far-fetched to try and stop them.
Why would they want to though? The shape and internal semantics of a Go mutex are not documented, so the only way to share one between C and Go is to look at the implementation details of the Go runtime. That seems like a place of hurt to me. Makes a lot more sense to me the other way around, where C exposes a "stable" API for a mutex that is shared between kernel and user space. We'd then provide a blessed implementation of that (using free atomic functions for example).
I saw that you added structs.HostLayout
to one of the tests and that made me think it would make a lot of sense to add this as a requirement for any struct passed via this mechanism. Because in a world where layout is reordered it doesn't make sense to pass anything else. In turn, I realised that this would mean atomic.Bool etc. don't work anymore, since they don't have a guaranteed layout. That's a shame but not without a work around, and I'm convinced that allowing sync.Mutex is a bad idea.
Playing that out in my head has me pretty convinced that we should adopt the same rules as package reflect or binary has:
- Only structs without unexported fields
- Containing structs.HostLayout
Maybe we can add special cases for atomic.Bool
etc. on the basis that they are always the size of the basic element and therefore no reordering is possible?
// | ||
// Doesn't check for loops since it rejects pointers. Should that ever change, a | ||
// visited set would be needed. | ||
func checkType(name string, t reflect.Type) error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have a hunch that you'll have to allow uintptr
because that is the natural way to represent a C pointer in Go. That only works as long as user space and kernel space are both 64 bit of course xD
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, we can allow uintptr if it's 8 bytes.
Reminder: we still treat btf.Pointer as 64 bits #963.
Pending the proposal in golang/go#70224, here's the current API proposal for returning Go pointers to 'off-heap' bpf map memory:
We need a bit of help from the Go runtime to manage the lifecycle of the underlying memory mapping. Currently, this mmaps over a piece of Go heap to allow the GC to track pointers into the bpf map memory, obviating the need for tying the mmap's lifetime to an
ebpf.Memory
. Instead, any Go pointers into the mmap will keep it alive, removing the risk of use-after-free.Clearly, this involves some dark arts, so we're not comfortable pushing this on users since
Collection.Variables
is populated by default. Any change in the runtime or allocator may break this, and the fallout means Go programs segfaulting without any hint as to why, which is not acceptable.--- Edit ---
Since the
runtime.AddManualMemoryCleanup
proposal may take a while to implement, land and ship in a Go version we target, I've revived the patch set and put the heap-mapping behaviour behindCollectionOptions.UnsafeVariableExperiment
.MemoryPointer
is yet unexported, sinceMap.Memory()
is typically called directly by the user and we'd need to store the feature flag in *Map, which doesn't feel ideal.Alternatively, we could go for an env feature flag instead of CollectionOptions (
EBPFUNSAFEVARIABLES=true
?), which would allow us to export MemoryPointer and transparantly switch between implementations.