3 ms·
Types help you enforce invariants. When you need to care about representation, representational types are quite useful. But it turns out, when we don't care a
by dllthomas 11y ago
Types help you enforce invariants. When you need to care about representation, representational types are quite useful. But it turns out, when we don't care about information interchange or a whole lot about performance, we usually don't care about representation as long as it's handled correctly, and a lot of automatic systems are okay (or better) at handling representation.
More of what we care about is domain relevant, and that's a place where enforcing invariants can be important. As an incredibly simple example, passing a "bid" price where an "ask" is expected could be an expensive error, and is unlikely to trigger an exception.
A type system like Haskell can be a big win, but we can enforce surprising things in something as simple as C. I had a project where I had several threads devoted to different activities, operating on their own static data. With a little creativity, I was able to track at compile time which threads a function could be run from and detect when I accessed data from the wrong thread. Without adding any run-time overhead, and while leaving me a free hand in organizing my data and functions in both my source files and my executable. It was tremendously convenient, when changes in specifications required me to move functionality from one thread to another, to be told everything that needed to change along with that. Over the course of the project, I can count on one hand the number of concurrency issues found in testing and only one - a complicated livelock - ever made it into production.
- versteegen 11y agoThat sounds really fascinating and educational. Could you possibly provide some more details or examples of how you accomplished that in C?
- dllthomas 11y agoI'm a bit sleep deprived (new baby) but I'd like to try to respond before the reply link goes away. There's a few layers to this. I threaded knowledge of what thread a function could run on through as the first argument. It was always called `th` and took the form of an empty struct. It turns out, if you pass an empty struct by value and don't do anything but pass it along, modern C compilers seem to generate identical byte code as if you removed the argument entirely (obviously, this isn't something you could strictly rely on, but it made no difference for correctness only efficiency, and if you're at the level where you care about that scale of efficiency you should be verifying all manner of assumptions when moving between compilers!). So now at any given point in any given thread-pinned function, you've got a variable in scope called `th`. Calling another thread-pinned function on the same thread looks like `foo(th, other, args)`. If that function isn't supposed to run on this thread, you get an error about trying to pass something of type `server_thread_t` as an argument of type `main_thread_t` (or whatever). Controlling data access could be done with getters and setters. I took a slightly different approach (which I'm not convinced is better, but it didn't cause me any pain this project), which was to define a macro used to declare global data that was bound to a thread, which would mangle the name and also create a no-op checking function. I had another macro that would take the simple name, check the thread, and resolve to the thing with the mangled name (as an LVAL). Having called that macro `L`, I now can treat `L(foo)` just as if it were a global variable named `foo`, and at runtime that's all it is, but at compile time my access to it is checked. I had a few other macros for putting a "lying" `th` in scope for those places I really needed to cross threads (`CROSSTH` for "I am accessing thread X from thread Y, `BEFORETH` for "I am touching this threads data before any threads were spawned", &c). Oh, the definition of the `th` argument in functions was baked into a macro so it looked like `int foo(TH(main), int something, char else)`. `TH` also added some attributes - I know there was `unused`, there might have been others... Hopefully that gives some sense of it. Let me know if you have further questions! :)
- versteegen 11y agoThank you for that write-up! Sorry that my response is so slow. (Seems I can still reply several days later, wonder what the limit actually is) That's definitely both a nice solution to the problem, and I found it pretty enlightening (having sadly not used anything as strongly typed as Haskell). In the case of global variables, if performance didn't matter then it would be possible to just pass around a pointer to a struct of the thread-local/thread-locked data for use directly. I spent a while trying to come up with some alternatives or generalisation of your approach, and found it was really hard to match the same guarantees without a run-time cost. One idea I got from your description is putting access to certain data/function in scope in a type-checked way. So you could put multiple such contexts (pointers or other access methods like functions or even macros) in scope at the top of the function/block, rather than having to type-check every access throughout the function using an implicit control (e.g. the 'th' variable.) But really, that's just equivalent to using the L macro like so: void inc_foo(video_thread_t th) { video_thread_data_t *vid = &L(video_data); // checked global access vid->foo++; vid->bar++; } I think something like this looks more natural though.