The `shared` debate, from my point of view

Steven Schveighoffer schveiguy at gmail.com
Tue Oct 23 21:17:16 UTC 2018


I wrote in a previous buried post that I finally understood the benefits 
of Manu's system of shared, and why he has proposed it with the implicit 
casting of unshared to shared. Here is the expansion on that.

Let me first start by trying to infer through his various posts and 
explanations where this thing came from. I'm going to spend a few 
paragraphs putting words in Manu's mouth, forgive me if I'm wrong (and 
please correct if necessary!), but I think it's important to understand 
the motivation for the proposal.

Obviously Manu has a lot of experience writing C++ code, and in C++, 
anything shared is "shared by convention". That is, you can share 
anything you want, there are no restrictions. The end result is that any 
pointer to any data must be treated like it's shared.

Treating every piece of data as if it was shared doesn't pan out well, 
because synchronizing memory across threads to avoid races isn't cheap, 
and pointers are used *everywhere*. So you must restrict yourself to a 
set of rules for sharing data. What I envision has been practiced in 
this case is that certain types encapsulate COMPLETELY thread-safe 
behavior. This means that whether the type is on the stack or on the 
heap, shared or not, it's going to defensively use locks or atomics to 
make sure no races can happen with that specific type. This is similar 
to stdout in libc, which is generally thread-safe, but uses locks even 
when only one thread is using it. (Side note: I myself have used things 
like shared_ptr in multi-threaded environments, and it does make things 
so much easier to have thread-safe primitives)

Somehow you must know which data is shared from outside the thread, and 
which data is local. This means that you have to have some way (probably 
by convention) for siloing this shared data away from your local data. 
The reason is because local data can be manipulated without 
synchronization, while shared data cannot. But clearly, any shared data 
can only be manipulated via the "thread-safe" types that are fully 
encapsulated and can't cause problems. The other data, you are not 
allowed to touch (again, this is C++, so by convention). Knowing what 
data can be manipulated is easy to discern based on the type of the data 
(e.g. Atomic<int>).

To recap: the silo that contains data shared from elsewhere can ONLY be 
manipulated through the fully anointed "thread safe" types. Normal types 
cannot be touched, because some other thread (the owner thread) can 
manipulate that data without sync.

------

Now, let's look at the *current state* of D. In D, shared data and 
unshared data are STRICTLY separated. One cannot simply share any data 
(like an int *) because that would now mean that int * is shared. Having 
a shared alias to unshared data trivially causes a paradox that now will 
result in races. This is the reason why implicit casting either way 
isn't allowed.

But that doesn't FIT the fully encapsulated "I can use this type shared 
or not", which can be used whether it's thread local or shared. So what 
Manu proposes is to remove this definition of shared, and instead of 
shared meaning "this data is shared", it means "you can only operate 
this data IF it provides a thread-safe interface". The thread-safe 
interface comes in the form of free-functions that accept the type as 
shared, including shared member functions.

So Manu's proposal (MP) is to do 2 simple things:
1. Basic data that is shared CANNOT be read or written via standard 
methods or operators. This enforces the convention of not touching data 
in the shared silo that is NOT thread-safe.
2. Standard data implicitly casts to shared.

The only way obviously to write or read shared basic types, therefore, 
is to cast away shared. But the intention from this plan is to only do 
this while inside a fully encapsulated and tightly controlled type.

And HERE is the key part I was missing -- those ints that have no 
thread-safe interface, are STILL USABLE by the original thread, because 
it still can have a non-shared reference to the data. All other threads 
can ONLY have a shared reference to the data, restricting them to the 
thread-safe portions of the type. In other words, you can use the 
Atomic!int type while the reference is shared, but not the int. Manu's 
quote (with contexts by me) here explains it all:

> In practise, and in my direct experience, classes tend to have exactly
> one [thread-safe member], and either zero (pure utility), or many such [thread-local] members.
> Threadsafe API interacts with [the thread safe member], and the rest is just normal
> thread-local methods which interact with all members thread-locally,
> and may also interact with [the thread safe member] while not violating any threadsafety
> commitments.

This requires a different mindset when implementing shared data. You can 
NEVER have a function that takes a shared int * and does anything with 
it. So all of core.atomic changes to only accepting `ref int`, and not 
`shared ref int`. Essentially, in order for a type to have an 
encapsulated thread-safe interface, it cannot have any other 
thread-unsafe means of manipulating the data. Obviously, this means 
basic types are useless as shared types unless encapsulated into a 
specially written type.

You want these sharable types in their own modules, so it can't have any 
unforeseen hooks into the private data, and it will actually work. It is 
a convention, although not too hard to follow. Some have mentioned that 
there are still loopholes (like accessing tupleof) that need to be 
addressed, but those should be addressed anyway.

Therefore, the rules are simple, they are sound, and they do accomplish 
a certain view of sharing data that will be useful in many cases. And it 
allows Manu's current model of sharing data to easily be implemented AND 
get rid of some of the convention in C++ by using compiler guarantees in D.

-----------

So here is my take on this: I propose that we still make basic shared 
data unusable without casting, but do not allow implicit casting to shared.

Manu's workflow and model is still doable without the implicit casting. 
Simply because, if you want shared data, declare it shared.

That is, if you have (with MP):

struct SharableType
{
    int x;
    Atomic!int y;
}

just declare it:

struct SharableType
{
    int x;
    shared Atomic!int y;
}

and share y instead of the whole thing. You still do not have to cast 
anything, and realistically, the other thread doesn't care about the 
other data it receives that isn't actually accessible. I see no reason 
to deal with the compiler preventing twiddling when it can be trivially 
prevented by not giving it to the other thread.

The objection I have seen most cited is that then the user is forced to 
cast data to shared to share it. I don't see how -- if you have the 
above you don't need to cast.

Simply put, casting unshared data to shared or vice versa means you have 
verified BY HAND that there are no other references to that data from 
that point forward. If the compiler can prove this, it can do the 
implicit cast. It works fine for immutable/mutable transitions, and can 
work here too. Casting to share data will not be a requirement for safe 
code, and will be rare in user code, if anywhere.

The only issue I see that can possibly cause problems is that it may not 
be easy or possible to separate the shared parts of data into its own 
type, which means you have to share it through an artificial reference 
type (one that contains only the sharable pieces). This can be automated 
and implemented via introspection.

One further benefit to keeping the cast explicit, is that one can write 
specific implementations knowing that data is not shared or is shared, 
giving a possibility of performance benefit that just isn't possible 
with MP (at least it isn't possible with compiler guarantees, obviously 
anything is possible if you follow conventions).

One thing that is problematic with MP, is that you can't actually pass 
ownership of thread-local data from one thread to another. This isn't 
actually possible without casting under the current shared regime, but 
with implicit casting from unshared to shared, you have introduced NO 
opt-in cast on the sharing side. This makes it impossible for the 
compiler or code reviewer to find the place at which you should be 
verifying the reference is unique (a requirement if you want to change 
ownership). The receiving side's cast back to thread-local can be 
abstracted (because you can wrap it in a type that assumes uniqueness 
and destroys the original).

Another thing that looks attractive from MP is you have this "carved 
out" section of your type that's only owned by your thread. This is 
great until you realize, you ONLY have access to it from your original 
reference. You can't send it away, get it back, and then manipulate the 
result. In this sense, it's VERY similar to const. So really it does you 
no good to associate the shared portions of the data with your local 
portions for the purpose of sending it away to other threads for a 
processing round-trip.

-------

To summarize, I think the reality is that we ACTUALLY can implement 
sharing as Manu wishes without implicit casting, albeit via library 
abstraction using introspection. I can easily see a library that allows 
you to pass a type that isn't shared, as long as it has shared pieces, 
and have that library simply restrict access to the thread-safe pieces 
via a wrapper. We don't need the compiler's help for that. So Manu can 
have his cake, I can eat my cake, we'll have a great big sharing of cake 
party, where nobody is racing, and everything is roses and lollipops.

That's all I can think of for now.

-Steve


More information about the Digitalmars-d mailing list