How to use ccall, cconvert, and unsafe_convert in a convenient and memory-safe way?

I am writing Julia code to conveniently interface a C library, which in turn is just an interface to a C++ library. Therefore, many times I have to store pointers to void as the C interface does not know anything about the actual types either.

For this purpose, I have several structs like this

mutable struct CxxObjectWrapper
  handle::Ptr{Cvoid}
end

which have no other purpose than to hold a Ptr to the actual memory (which is managed through the C library), and to be able to use it conveniently with multiple dispatch.

My questions are regarding the proper use of CxxObjectWrapper when used in ccall. Many C functions look like

long foo(void *cxx_object_wrapper);

to which I write the following Julia function:

function foo(obj::CxxObjectWrapper)
  ccall((:call_foo, libname), Clong, (Ptr{CVoid},), obj.handle)
end

Now my questions are:

  1. Is this a problem in terms of garbage collection (specifically, the obj.handle part)? E.g., should I wrap the ccall in something like GC.@preserve obj ccall(...)?
  2. What would be the safe and canonical way to do this conversion automatically? Should I define something like
cconvert(::Type{Ptr{Cvoid}}, x::CxxObjectWrapper) = x.handle

or do I need to use unsafe_convert? I am a little at a loss here, since the docstrings imply that cconvert should not return a Ptr but I am not sure if this applies in this case (or if it just means it should not return a Ptr to the object itself).

I used cconvert when doing this in my code:

cconvert(::Type{Ptr{Cvoid}}, x::CxxObjectWrapper) = x.handle

According to my read of the documentation Julia ensures that the object is not garbage collected during the C call if it gets the pointer via the cconvert call.

No that’s the job for unsafe_convert.

Does this answer refer to the suggestion of @pixel27 of using cconvert for my purpose, or is it an objection against the statement that

?

If I combine the information of the docstrings of cconvert (which says Neither convert nor cconvert should take a Julia object and turn it into a Ptr.) and unsafe_convert (Convert x to a C argument of type T where the input x must be the return value of cconvert(T, ...).), does it mean I should define both

cconvert(::Type{Ptr{Cvoid}}, x::CxxObjectWrapper) = x

and

unsafe_convert(::Type{Ptr{Cvoid}}, x::CxxObjectWrapper) = x.handle

? To me, that seems like overkill…

That was a directly reply to @pixel27’s comment but discourse automatically removed reply reference to the previous comment again… Sign…

OTOH,

is indeed wrong. The guarantee is that the returned object from cconvert will be valid, so you can derive pointers from them.

I think this is the default.

Also, if it’s used many times in the code, then defining two more functions instead of one shouldn’t be too much harder. If it’s just a one off call, you can also use GC.@preserve…

1 Like

You are right about the default:

cconvert(::Type{<:Ptr}, x) = x

Thus I will just define the appropriate unsafe_convert then. Thanks for the clarification!

Ah, so in my example above, I have to use GC.@preserve or else I am not memory safe?

No, I’m saying if this is the only place you need the unsafe_covert, you can replace that with GC.@preserve at the ccallsite instead. Both are valid options and they are roughly equivalent here…

Sorry, I was not very clear: In my example above, if I don’t do anything, ie, neither unsafe_convert nor GC.@preserve, then I can get into trouble? Or put differently, the example above is broken if left as-is?

If you are talking about

then yes. (note that even if you define unsafe_convert it won’t fix this automatically. You’ll need to replace the obj.handle with obj of course…)

And also to clarify, it’s only a problem if you have finalizers to manage the handle based on the lifetime of the parent struct (You didn’t say this explicitly but given it’s mutable I assume that’s the case). If you aree doing full manual management of the C memory then you don’t need anything like this. The pointer value will never be wrong. It’s only freeing of the underlying memory by julia (through finalizer) that’s of concern here.

3 Likes

You are right about the handling through finalizer. The actual definition of CxxObjectWrapper looks something like this:

mutable struct CxxObjectWrapper
  handle::Ptr{Cvoid}

  function CxxObjectWrapper()
    # Create C++ instance through C library and retrieve pointer
    handleref = Ref{Ptr{Cvoid}}(C_NULL)
    ccall((:CxxObject_new, libname), Clong, (Ref{Ptr{Cvoid}},), handleref)

    # Store pointer to C++ object in new Julia CxxObjectWrapper instance
    x = new(handleref)
    finalizer(x) do x
      ccall((:CxxObject_delete, libname), Clong, (Ptr{Cvoid},), x.handle)
    end

    return x
  end
end

However, even though I feel like I read almost everything on the topic of GC and C memory management either here, on SO, and in the docs and docstrings, I still can’t seem to wrap my head around the fact why I would need something like unsafe_convert to avoid memory issues in functions like this:

function foo(obj::CxxObjectWrapper)
  ccall((:call_foo, libname), Clong, (Ptr{CVoid},), obj.handle)
end

As far as I can tell, there is no way that instance obj is not referenced anymore, since obj is just another name for the same instance that was passed in at the call site, e.g., bar in case it is called as foo(bar).

The only possible issue I could see is when foo() is called with a temporary instance of CxxObjectWrapper, e.g., foo(CxxObjectWrapper()). But in this case, I still don’t see a need for a “special” GC-aware function: A simple

handle(x::CxxObjectWrapper) = x.handle

should do the trick as well, since in this case,

function foo2(obj::CxxObjectWrapper)
  ccall((:call_foo, libname), Clong, (Ptr{CVoid},), handle(obj))
end

obj is always referenced somewhere. Or am I completely off the track here? Sorry for asking so persistently, but from your many other contributions on Discourse I feel like you are exactly the right person to ask these questions and get a reliable and well-founded answer :grimacing:

obj may be GCed before ccall and obj.handle (julia will rewrite handle(obj) to it in this case) will be passed by value; the compiler has license to reorder and even elide creation of objects. We recently had a bug like this and it was very hard to track :wink:

No that’s not the case.

Well I don’t see how this can help even if foo get passed a copy. It’ll at most make things worse since your x could be a copy too.

Now what does that mean. If what you are saying is that the obj is always either

  1. referenced by a global variable
  2. appears in a GC.@preserve argument
  3. return value of cconvert during a ccall (i.e. when the control is in the C code and have not returned yet).

Then yes, you are right. You would not need to do anything to make the use of obj.handle valid. Otherwise, not, there’s basically no other well defined reference of obj. Just because you have a local variaible has absolutely nothing to do with the lifetime of the object.

Ah, OK, now I am getting closer. What you are essentially saying is that the compiler may decide to create a copy of bar/x to pass it into foo/handle? Or where could this copy come from? I always thought (but don’t ask me where I read it) that in Julia function arguments are always passed by sharing/reference, unless the argument is isbits, in which case a copy can be made. However, any struct with a finalizer can never be isbits, since it has to be mutable to have a finalizer. Or where am I missing something?

I think these two statements, in their simplicity and clarity, are absolutely vital to keep in mind when working with ccall, and should be in the official documentation! Even though the topic is briefly touched here, I think your answer plus a counter example (e.g., “do not use ccall(..., a.b) if b’s validity depends on a’s validity, instead use ccall(..., a) and an appropriately defined cconvert/unsafe_convert)”) would help the unsuspecting user (like me) who has just enough knowledge to be in danger of shooting themselves in the foot :wink: Do you think it would be advisable to create a PR to the manual for this?

To sum it up, if I get everything correctly, with my definition of

unsafe_convert(::Type{Ptr{Cvoid}}, x::CxxObjectWrapper) = x.handle

I am able to pass obj directly to ccall, which in turn will call automatically call cconvert (as per the docs), which in the absence of other definitions will select the method in Base,

cconvert(::Type{<:Ptr}, x) = x

and thus obj is always properly referenced (as per your third condition above). Thank you very much for these answers and your patience, this helps a lot!

No! I was following your logic that obj might have been a logic. No such copying is happening anywhere.

I’m asking because if you think adding such a function would help, there’s a major misconception somewhere so I want to know why you think it helps.

No. That’s not true either. It’s not true semantically, a reference is always passed, and it’s not true performance-wise, a pointer is passed for large structures.

Thanks for the clarification. I thought handle(x) = x.handle could prevent the GC from cleaning up x, since it still “sees” it as an argument to handle(x). However, I clearly still have a considerable way to go before I fully understand when and where the GC can (and will act), but for now I am happy enough with my half-knowledge plus your valuable input.

That’s good to know for at least two reasons: One, it is interesting from a technical point of view, and two, it tells me that I still have a very naive concept of what goes on behind the scenes in Julia (apparently much, much more than I thought). Coming from the C++ world (where it was usually very easy to reason what goes on by doing a static analysis of the code), I still have a lot of catching up to do with respect to Julia’s internals. For now, however, I think I am happy enough with the information I gathered from our discussion here, but I might revisit this in the future when I encounter similar issues again.

Note that value passing in C++ can be passing a pointer as well. The compiler may not have enough information to elide the copy though.

Also that is what I was suspecting. In julia, what you write in one expression has absolutely nothing to do with anything. ptr = handle(obj); ccall(....., ptr) is absolutely equivalent to ccall(...., handle(obj)). Intermediate result in a expression get no special treatment in any regard.

2 Likes