- [Instructor] Okay, so, we finished talking
about the cache coherency and fault sharing issues
which are really interesting when
it comes to multithreaded software.
But what I want to do is start off
with a very simple data race problem.
I think if we can look at a simple data race
then you'll get a better understanding
of what we mean about synchronization here.
So, in this piece of code, what we're gonna do
is we're gonna have our global
variable right there on line 16.
And it's called counter.
So there's my counter variable.
It's my global counter, right?
And what I wanna do is launch two goroutines.
Goroutine one
and goroutine two.
What these goroutines are gonna be doing
is incrementing this local counter, right.
I now have a synchronization issue.
You get to go, you get to go, you get to go, you get to go.
This is not orchestration.
They're not talking to each other.
They need to get in line and take a turn.
Incrementing the shared state, which is counter.
Okay, great.
So, I go ahead on line 21.
I define a constant of two for two goroutines.
I use my WaitGroup orchestration mechanism.
'Cause we're gonna have two goroutines.
I wanna manage that once they're done
then we can shut this program down.
So we add two.
And then in the loop, I go ahead
and I use our literal function.
Okay, literal function construction here.
I call all the function down here, right here.
And we put the keyword go in front of it, there it is.
I now have my goroutine one and goroutine two.
There it is, right there.
And then, these goroutines do three operations here.
It's a read,
modify,
write. There it is,
read, modify, write.
So basically the goroutine comes in,
it does a read, does a modify, writes it back.
Right, read,
modify, write it back.
And this in come in, do a read, modify, write it back.
Read, modify,
write it back.
So every time we run this program,
we should get the answer of
four.
So let's navigate to where this program is, real quick.
And, we're gonna do a go build.
And we're gonna run it.
Now every time I run the program,
we see exactly the output that I'm expecting.
We have the read, the modify, and the write.
Just like we drew it here.
Two goroutines doing two increments,
the answer should always be four.
The answer should always be four.
And every time I run this program,
we get the answer of
four.
I'm happy, this program's working.
We put it in production.
But, we've been running on bare metal.
And, one day we decide we're gonna move to the cloud.
Which are much noisier and busier machines.
And once we do that, things start to change.
Because understand that there's nothing
atomic going on with these statements.
And to prove it what I'm gonna do
is just add one more line of code.
I'm gonna add a print statement here.
Which is a system call.
To display value, the local variable,
before we write it back to our global state.
Watch what happens just by adding
this one line of code on line 38.
Let's build it.
And let's run it.
Do you notice that the final counter
is no longer four, it's two.
And do you notice that this doesn't
say one, two, three, four.
It says one, two, one, two.
Hum, how is it that just this line of code here
has suddenly changed the behavior
of this program and for the worse.
How is it and how would that affect us?
How could, when I said we went
from bare metal to the cloud, it's a noisy machine.
How could I'm, how am I really trying to simulate that?
Let's go back to our chart here, okay.
Let's do this all over again.
Now with the print statement in place.
So we can see it.
Okay, brilliant.
So what do we do, we again do our local read.
There it is, read zero.
And we do our
local write, there it is.
But when it comes to the print statement,
remember that is a system call.
And now what we're seeing is the scheduler going
oh, you want to make a system call?
Well we're gonna now move you from a
running state into a waiting state.
Guess what we just got, context switch.
This goroutine comes off the P.
Hum, which now means that this goroutine gets to run.
No problem.
It reads the global state, zero.
It modifies it's local variable.
And then it goes to print.
And, the scheduler goes okay, context switch.
Now, once we've done the context switch.
This system call's probably done.
And the scheduler chose G1 again.
Which now means that it's time to write it back out.
Which it does, but this goroutine
has no idea that it's stopped running.
These goroutines are kinda blind to that, right.
They have no clue.
So it does the write, and it comes back and does the read.
Does the modify and then what happens?
Well we've got another context switch.
Which now means we're back here.
Now remember we talked about how
the hardware level in the caches
once one core changed something
it marked those cache lines dirty.
Well guess what, I don't have any special snooping protocols
here to tell the goroutine that its copy
of the global state has changed.
We have nothing here indicating to the goroutine
that what it's working on right now is dirty.
Fact this goroutine doesn't even
know that it's stopped working.
So what does it do?
It goes ahead and writes back,
one.
And it reads it,
modifies it,
and then we have another context switch.
So this is why we're seeing some very nasty behavior.
Because this goroutine here has no idea
that the global state has changed.
And we start in this situation where only one goroutine.
Only one goroutine is really doing the modification.
Now the print statements can happen almost in any order.
You're seeing one, two, one, two.
Don't read into that with what I did on the board.
Okay, because now we're dealing with
running really in parallel.
I am running against two P's.
Each one of these goroutines is running on it's own M.
Which is really running on it's own core.
And we've got other synchronization issues
with the output, don't read into that.
What's important here is that print statement caused
a context switch in the middle of these three lines of code.
And therefore these three lines were
no longer atomic the way they were before.
But we were getting lucky before.
There were no guarantees, remember I told you
synchronization orchestration requires that we demand.
Demand, right?
The synchronization, the orchestration.
You have to demand it, you can't just ask for it
or hope it's going to happen.
We've lost it.
And I need you to understand that
no line of coding go is atomic either.
You see this ++ which is a read, modify, write.
A read, modify, write operation.
I've kinda simulated a larger scale.
But value++ is also, at thought, at read, modify, write.
You have to understand there are three lines
of assembly language code behind that.
And, even at the operating system level,
any one of those three lines of code
can be context switched.
And then that assembly into machine code, machine code can
even be broken down into more like lower level instructions.
That any sort of preemptive context switch can happen.
There is no one line of code, not even the one
I've highlighted here, that is atomic.
You have to demand the synchronization by coding it.
That is your responsibility, alright.
So we've got this logic here, and what I wanna know then is.
How do we demand that these three instructions,
or at least the increment of the counter,
happens synchronously?
Okay, especially in our multithreaded environment.
Where we're running on multiple cores.
Now you've got two choices here.
You can either use the atomic instructions
or you can use mutexes.
Now atomic instructions are your fastest way to go.
Because they're at the hardware level, okay.
The hardware takes care of synchronization.
But they're limited to just four or eight bytes of memory.
They're great for counters like we have here.
When you really have multiple lines of code
that need to be synchronized in an atomic fashion.
That's where mutexes are gonna come in.
So let's start with the atomic instructions
to clean this up.
Start with the atomic instructions to clean this code up.
Now, if I want to use the atomic instructions.
I'm gonna be using the sync/atomic package.
The atomic package under sync.
And the atomic package has a really nice API,
I'm gonna show it to you.
But when we use the atomic package
we need precision based integers.
We can't just use int anymore.
We've now gotta specify specifically
are we using 64 bit or 32 bit integers.
It has to be consistent across
every platform that we're working on.
That's just a constraint of the API.
So I've moved counter now to a 64 bit integer.
And we still got our constant of two.
We got our wait group for our orchestration there.
But notice that I've taken all
three lines of code out of the program.
And reduced it down to one call to AddInt64.
Now if you've worked with the atomic instructions before
in other languages, this API should look familiar.
Look add a 32 or 64 bit, or add an address.
Compare and swap, what that does
is it compares two values atomically.
If they're the same, then we swap the values out.
Load is purely just for reading.
Store is for writing.
And this swap is a swap regardless of the compare.
Basic atomic API set.
And the way synchronization works here,
is by accepting an address in the first parameter.
And all synchronization is at the address level.
So if you've got multiple goroutines like we're gonna have.
Calling AddInt64 for the same memory location,
like we're gonna have.
Then these two goroutines get in line.
We have synchronization, they get in line.
But if these two goroutines called
the same AddInt64 for different memory locations,
they can continue to run in parallel.
So it's the address, right, that the hardware is going
to pick up on and then cause everything to go in line.
This is our fastest way.
But, what if we really wanted
to maintain our logic as before.
I wouldn't necessarily do that here.
This is the best way to do that counter work.
But let's say we did have those three lines of code.
I wanted to maintain the three lines of code.
And I had to make sure that
nothing interrupted those three lines of code.
They had to run one after the other
every time without any interruption.
Alright, this is where your mutex comes in.