thread-safe interfaces Jim Blandy (22 Dec 2003 07:40 UTC)
|
Re: thread-safe interfaces
Michael Sperber
(23 Dec 2003 09:34 UTC)
|
Re: thread-safe interfaces [correction]
Jim Blandy
(24 Dec 2003 22:27 UTC)
|
I remember someone (Richard K.?) remarking at the Scheme 2003 workshop that any discussion about threading seems inevitably to turn into a discussion about what "thread" really means, so let me show my colors up front. When I say "thread", I'm referring to all of the following: - The pleasant notation for some kinds of algorithms and user-visible behavior. The Concurrent ML / eXene attitude. This view of threads can easily be implemented entirely in the interpreter, with no operating system support. - An abstraction that helps programmers write code which can run in parallel on multi-processor or hyperthreaded systems, for a performance gain. - The existing "thread" facilities provided by many popular operating systems. If I want to write Scheme code which acts as a plug-in or an extension to any existing multi-threaded application out there (Mozilla? Apache?), I can never be a first-class citizen if my Scheme system's "threads" are not the real thing. Sure, I could always fake it in various ways, but in my experience it'll always show through in some annoying way whether you're doing it Right or not. If I'm reading the proposal right, the interface does not support this notion of "threads". If doing so simply isn't interesting to the SRFI-50 authors, I can understand that; it would be a major design change, and the best ways I know of to address it do add some run-time overhead, and do complicate the interface, while providing no benefit to single-threaded programs, which I would assume are still overwhelmingly the most common sort. ---- Assuming it is interesting: The proposal states that "the garbage collector may run whenever an object is allocated in the heap." In context, I think that really means, "the garbage collector may *only* run..." In a multi-threaded context, I think this must mean that the collector may only run when any thread allocates an object on the heap, which isn't much of a restriction at all; the collector can run at any time. If that is so, then the way the proposal suggests C code should refer to Scheme values makes it impossible for the collector to find and relocate heap references. The C compiler may have stashed them anywhere, in disguised forms, and it is probably unwilling to tell you much about where they are at any given time. The values returned by macros like SCHEME_EXTRACT_VALUE_POINTER and EXTRACT_STRING also can't be trusted long enough to be useful. The Java Native Interface handles this problem by essentially forbidding C code from ever referring directly to a GC'd object. Instead, C code may only handle "references" to GC'd objects. This restriction is enforced by the type system. References are explicitly freed objects, which is way people are accustomed to working in C. And because the explicit-free model is a complete pain in the neck, the JNI tries to ease that pain by segregating references into "local" and "global", where "local" references are freed for you at a convenient time. (It doesn't work to make the scheme_value type the reference type: references do need to be freed, somehow, whereas assignment to a properly protected scheme_value variable is sufficient to drop the reference to the object it used to refer to.) At http://svn.red-bean.com/repos/minor/trunk/include/minor/minor.h, there is a sketch of a C API interface to a Scheme system that can support all the goals of SRFI-50 (precision; permitting copying collectors; etc.), and also support multithreaded programs. The comments at the top of that file explain the approach I've outlined above in more detail. This is a work in progress; it has not undergone any review to speak of. But I think it does address the concerns I raised above. I've included the relevant section at bottom. I think I have a pretty efficient implementation of this, too: - The implementations of the functions in the interface do not need to hold a mutex while they operate on the real Scheme values. "car" does not require the acquisition and release of a mutex. The only synchronization that actually takes place is between a thread and its own signal handlers, so it can be done in C with assignments to volatile variables --- ordinary, unsynchronized stores that the compiler has less freedom to reorder and optimize out. - Local references are allocated in small constant time, and all the local references owned by a particular call are freed en masse. It's all untested; these are just assertions. The rest of the code is there in that web hierarchy, if you don't mind the programmerly equivalent of seeing me in my underwear. The drawbacks compared to the proposed SRFI-50's approach are, as I see it: - SRFI-50 requires the programmer to do pretty simple annotations (the GC_PROTECT macros), whereas the JNI approach makes the programmer responsible for freeing references. Sure, the "local reference" tweak handles the common cases, but you still have to think harder to make sure you got it right. - SRFI-50 allows code to directly manipulate references to Scheme objects, where as the JNI requires the creation and destruction of reference objects as well. ---- /* mn_refs: References to Minor objects. */ /* To support garbage collection, the Minor run-time needs to be able to find all the Minor objects C code is examining at any given time: if C code has access to an object, then the garbage collector will make sure not to free it. The functions in this interface never return, or accept, direct pointers to Minor objects. Instead, we introduce one level of indirection: they return and accept `mn_ref' objects, which refer to Minor objects. C code: mn_ref *x; +-----+ | . | the mn_ref +--|--+ +-----+ `--------->| . | +--|--+ ,--------------. `-------->| minor object | `--------------' (The Minor objects themselves, of course, point directly to each other; mn_refs are strictly an interface to non-GC'd languages.) For example, the functions to construct and access pairs are decared like this: mn_ref *mn_cons (mn_call *c, mn_ref *car, mn_ref *cdr); mn_ref *mn_car (mn_call *c, mn_ref *car); mn_ref *mn_cdr (mn_call *c, mn_ref *cdr); (Ignore the 'mn_call' arguments for the moment.) The Minor run-time keeps a list of all the mn_refs that have been given to C code, and protects all the objects those mn_refs refer to from being garbage collected. We try to make mn_refs as lightweight as possible. C code using this interface is responsible for making sure every mn_ref it is given gets freed. Since this can be a rather complex task to manage, we introduce some wrinkles to make the common cases easy. Mn_refs come in two kinds: local, and global. - A local mn_ref is owned by a particular call of a C function by Minor code; when that function call returns, all the local refs it owns are automatically freed. Specifically, when Minor calls a C function, it passes an extra argument: a 'mn_call *' pointing to a call object created just for that Minor->C call. The functions in this interface that construct local references all take a 'mn_call *' as their first argument, and return local mn_refs owned by that call (with some exceptions, all marked as such). The call object also owns any mn_refs the C function may have received as arguments from Minor code. When the C function returns, all the local refs its call object owns are automatically freed, along with the call object itself. This means that, in the common case of Minor calling a C function that does some work on its arguments and then returns without stashing any Minor objects in global variables or data structures, the C code doesn't need to do any extra bookkeeping; once it returns, all the mn_refs it accumulated --- all local --- will be freed. However, the convenience of local mn_refs comes at a price: local mn_refs should not be stored in global variables, nor should they be shared with other threads. To get around this problem, you can promote a local mn_ref to a global mn_ref: - A global mn_ref lives until you explicitly free it. Global refs can be shared amongst other threads, and stored in global variables. However, since global refs are never automatically reclaimed, C code must take care of freeing them at the right time itself; global refs are more work to manage. Like any other kind of object shared between multiple threads, it's up to the user of this interface to ensure that one thread isn't using a global reference while another thread is freeing it. This interface provides functions to convert local mn_refs to global mn_refs and vice versa, and a function to explicitly free refs when necessary. There are also some cases where C code will need to explicitly free local mn_refs, before the call that owns them returns. For example, if a loop traverses a list using mn_car and mn_cdr, each element is returned as a local mn_ref. To avoid consuming storage proportional to the length of the list for all those local mn_refs, the loop must free them as it goes. To accomodate cases like this, there is also a function that explicitly frees a local mn_ref. Almost every function in this interface takes a 'mn_call *' as its first argument; unless stated otherwise, any mn_ref values it returns are owned by that call. When we use the call in the obvious way, we don't bother to give it a name in the prototype, for (a tiny bit of) legibility. The exception is, obviously, the function that gives you your very first call: mn_thread_first_call takes no arguments, and returns a call object. Since calls can't be shared amongst threads, every thread that wants to use this API must call this function. The first time it is called, we initialize the Minor library. Some subtleties: - Except where we state otherwise, all the functions in this interface promise to free any local refs they allocate before they return (other than the local ref(s) they return). This allows these functions to be used within long-running loops without accumulating local refs the caller has no way to free. - You may notice that even functions that don't need to allocate or return local references still expect a call argument --- if there's no need to indicate who should own any new local refs, why does the function need to know the current call? The collector also uses calls internally, as a cheap way to keep track of which threads might be accessing heap objects. If you've ever touched a heap object, you must have a call. So we can take care of adding a thread to our list when we allocate them their first call --- instead of having every function in this interface check to make sure the calling thread is registered. - You may notice that this interface doesn't provide any functions that change the heap object a reference refers to. This is an important property, because it allows this interface to be perfectly thread-safe without doing any memory or execution synchronization while accessing references. Where the user's code shares references between threads (global references only, please), it's the user's responsibility to do the right sorts of mutual exclusion to make that sharing kosher --- and that takes care of us, as well. The users manage the same synchronization burden they've always had; we don't add to it, in complexity or run-time overhead. (The functions mn_to_car and mn_to_cdr look a little like side-effecting functions, but the specification actually says they destroy the original reference and return you a fresh one. And it's always up to the client code to ensure that nobody destroys an object while someone else is using it. So the calling thread must have the only live pointer to the reference.) */