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