- Now, your mutex is going to allow you
to create a block of code,
or multiple lines of code,
and treat that as one atomic operation.
So this time we're just gonna use the same package,
and since we're gonna be making lines of code atomic,
I can go back to my int 32.
Now I've made this mutex as a global variable here,
we don't really use global variables,
I'm just showing you mechanics right now.
Most of the time a mutex should be a field in some struct,
and once you have a mutex as a field in a struct
that struct can no longer be copied.
A copy of a mutex creates a new mutex,
this is all value semantics here.
So make sure that you never make copies
of the values that have mutexes inside of 'em.
But you see it's usable in its zero value state.
So this mutex creates either what we call
a critical section of code that is atomic.
Let me kind of draw out a visual
of what I mean by a critical section of code that is atomic.
I like to think of mutexes as creating rooms in your code.
And so we can use this mutex
and say these lines of code,
whatever they are, these lines of code, right?
Multiple lines of code.
We have to make sure that they always run atomically,
one after the other with no interruption.
So we're gonna put these lines of code in this room,
and the scheduler kind of acts like a bouncer,
just like if you went to a bar.
All of these goroutines are people
who are coming to this room,
they all want in but only one goroutine, or person,
is allowed in the room at any given time
because if we allow multiple people in the room
we're going to have our data race, right?
We're gonna have our situation
where we cannot be modifying, read and or modifying,
the same memory location at the same time,
or in this particular case,
we can't let two goroutines execute
these four instructions at the same time.
So the scheduler acts as a bouncer.
Now, when you get to the room,
what you're going to do is ask the scheduler,
"Hey, I want a lock, I want a lock, I want a lock,
"I want a lock, I want a lock, I want a lock."
And that lock call is going to be a blocking call,
and only one goroutine can get the lock
at any given time.
So eventually the scheduler's gonna go,
"Okay, you goroutine right here,
"I'm gonna give you the lock.
"You get the lock, you come in."
Which means all the other goroutines
are now waiting their turn.
Now, don't assume that a goroutine
that gets to the room before another
is gonna go first.
There are algorithms here to try to make things fair.
But just because this goroutine
got here before this goroutine
doesn't mean this goroutine gets in the room first.
So it's like when you go to the club,
somebody shows up in a fancy car
and they get in ahead of you.
It's all the same.
It is not deterministic,
which goroutine, when all things are equal here,
is gonna get into the room first.
Though the algorithm is doing its best
to keep things fair.
Now, once you're in the room,
you're in it, you get to do these four things,
and then you have to leave the room.
Now, once you leave the room,
this is what you call unlock.
We're gonna say unlock.
That's gonna free the scheduler at that point,
to come in and let another goroutine in.
Again, we only allow one goroutine in the room at a time.
So the mutex is giving us the ability
to create a critical section of code,
to make sure this code executes atomically
no matter how many instructions.
But there's a real cost to mutexes,
we have to be aware of this, and that's latency.
And the more goroutines that are waiting to get in,
and the longer this goroutine takes to get out,
the longer the latency is.
This is technically back pressure internally,
inside your software.
We're gonna wanna be able to measure this back pressure,
we have gotta reduce it.
You've got to make sure you do the least amount of work
that has to be atomic.
Because if you do more work that needs to be atomic,
you're putting more latency or back pressure
inside the system.
But you've gotta make sure that it is atomic,
I've seen lots of code
where they've tried to cut some corners on latency,
and then these instructions end up
not really running atomically.
I'll try to show you an example of that.
This is your mutex.
So look at what we do here.
We've got our two goroutines,
we've got our wait group, we add two.
There's the two goroutines again,
and now here's the call to lock and unlock.
Now there's something that I do, personally,
you're not gonna see this, probably,
in other people's code.
What you might have noticed
is I've created an artificial code block
with the curly brackets.
It does create a new level of scope,
but what I love about it
is it gives us a visual in the code
of what instructions we're trying to execute atomically.
In other words, it's able to create the room.
I love this, this is really important,
because there's another rule here
and I'm gonna put my foot down.
The same function that calls lock
must call unlock.
You cannot separate these calls
between two different functions.
You're gonna miss a lock or an unlock,
and you're gonna have a deadlock situation.
Remember, if this goroutine doesn't call unlock,
you now have a deadlock situation.
All these goroutines are stuck because they can't get in.
So I want the lock and the unlock
to always be together, always be together, okay?
And some people like to use the defer,
now we haven't talked about defer.
Defer says execute this piece of code
after the function that you're inside right now returns.
So it's, have the function do its thing,
return, call the defer.
I can't use a defer here, because I'm in a loop.
But what you might see a lot
in the beginning of a function,
at the very beginning of a function,
is somebody calling lock
and deferring the unlock.
But I would wanna see this type of code
as like the first two lines of a function,
that would be fine.
But I'm inside of a loop,
if I do this I'm gonna be in a lot of trouble,
because Go doesn't let the same goroutine
call lock twice.
It's something that, again,
Go is about keeping things simple.
There are languages like C++,
I've done it in C#,
where the same goroutine, or in that case thread,
can call lock multiple times as long as it calls unlock.
That doesn't exist in Go.
One lock to one unlock.
So look what we've done.
We've now said we're gonna lock these three lines of code,
these three lines of code have to run atomically.
There cannot be a context switch
to another goroutine that also wants in this room.
And by doing that, what we're gonna now get,
example three, if we do that,
and I run go build,
now what's gonna be great
is I now backed out to four, I have four.
And if I put the fmt crawl in here,
print line value, like we did before, remember.
If I do that and then I build it,
then I run it,
now we're gonna still see four,
because what we've now done with the mutex is said,
"I don't care that we're making a system call here,
"that is atomic now,
"so we're gonna have to wait."
Which just added more latency to our software
by doing this print here,
because we gotta wait for the system call to get service.
This would be a case where I'm like,
"Yo, yo, yo, yo, don't do the print inside the mutex,
"you've just added extra latency costs here.
"You gotta just do the bare minimum."
The bare minimum.
So always inside of a mutex,
you gotta look to validate we're doing the bare minimum,
but we're still maintaining a full level of atomic.
Everything's still atomic.
I can tell you this.
If I do a code review,
and I see something like this, which people do,
this is a really bad smell.
I've seen people do this.
They say, "You know what, Bill?
"I wanna get out of this mutex really, really quickly.
"So I'm gonna do the following.
"I'm gonna create a local variable out here,"
we'll have to do that.
Value int.
"And what I'm gonna do is go into the mutex one
"to read the value out, Bill,
"then what I'm gonna do is value plus plus,
"and then, Bill, what I'm gonna do
"is use the lock one more time
"to write it back, and look what I've done, Bill,
"I've made my mutex lock and unlock very, very fast."
No, what you've done, is lost all ability
to have these operations atomic.
Because even though your read is atomic, guess what?
Your write, here, isn't.
And that means another goroutine can still come in
and read a dirty value.
So if I see code inside of a function
that's using the same mutex like this,
lock, unlock, lock, unlock,
this is a big smell
that we really don't have an atomic piece of code.
These are code smells that you can find.
So we're gonna have the one lock and unlock here,
gotta make sure we do everything that we need
and only what we need
to reduce the latency costs.
This is gonna be your death.
Remember I told you performance
comes from latency on networking in I/O,
latency on that garbage collection, right?
How much pressure we put in there.
The next thing is how we access data,
how we access the hardware,
this is also gonna be a big cost, right?
What kind of back pressure do we create
inside of our software,
now we're dealing with algorithm efficiency issues,
but with latency because we can hold those locks
way too long.
But we've got it here.
Now, one other thing is the read, write mutex.
The read, write mutex says the following.
"It is okay to read memory.
"Reading memory doesn't cause a problem.
"It's only on the mutation.
"And what if you suddenly need ten goroutines
"to read something at the same time?
"There's no reason to synchronize that,
"that would be a waste of latency or back pressure.
"We only need to stop the reading
"if we wanna write."
This is where the read, write mutex comes in.
So here's our read, write mutex right there.
And what are read, write mutex does,
is it allows us to have multiple reads
across that one write.
So here we are with our slice of strings,
there it is, a slice of strings
is our core data structure here.
We're gonna be synchronizing,
this is all synchronization,
so here it is, here's our slice.
Remember that your slice is a three word data structure,
I'm drawing it horizontally right now,
which is our pointer, our length, and our capacity.
Three words, three word data structure,
and our read, write mutex also is usable
in its zero value state.
Now, on line 22 we have our read counter,
again, this is gonna be a global variable,
to track the number of concurrent reads.
Now, look at what we do here.
I'm gonna go ahead and launch a single goroutine
whose entire job it is
to write to this three word data structure.
In other words, it's gonna be doing an append.
And then I have another set of goroutines,
I've got eight goroutines, eight of 'em.
Whatever I have here.
And they're gonna be doing reads,
they're gonna be reading this data structure.
All of them can read at the same time,
that is not a problem,
it's the write that hurts.
So look at what we do.
There it is, on the lock.
Any time we wanna do a write, we have to lock.
Leave the lock,
and then we're gonna do the unlock on the right.
What this is gonna tell the scheduler is,
"Don't let any activity go against this data structure
"or these lines of code, in this case."
And these lines of code are doing two things,
they're reading the read counter
and it's doing an append on line 71, there,
to modify this data.
And then what we have on the reader,
which you've got multiple goroutines,
is it's asking for the R lock,
with the R unlock.
What that is saying is,
"Allow these goroutines to go ahead,
"and do this length call at the same time,
"because we know it's not modifying behind it.
"If you ask for an R lock, you better just read.
"You start writing here,
"we're gonna have synchronization issues."
But we're using the atomic instructions for this
because eight goroutines might want to increment
add in 64, that local variable.
This is really nice.
So the scheduler is doing synchronization for us.
When we wanna write, we ask for the lock,
and when we ask for the lock,
then that goroutine here
is gonna have to wait for all the read locks
to call read unlock.
The scheduler will not give out another read lock
until the writing finishes.
Once the write finishes, we can give out the read locks.
So if we run this program here,
example four,
what we're gonna see on the output
is this synchronization between reads and writes.
So here we started off with a length of zero on the slice,
and you can see how many goroutines are reading
the slice length at the same time.
There is all eight.
But eventually, randomly here,
the goroutine wakes up and says,
"Hey, I wanna write," to the slice,
"I wanna append a value."
Notice that all the reads stop.
We do the write, the length is now one,
and then all the reads start up again.
Look at the scheduler doing synchronization for us
through the mutex,
because every time we wanna write,
we stop all the reads,
we modify, we start reading again.
It's a really beautiful data structure
for dictionaries and things
where you're doing multiple reads,
and then eventually you have to stop on the write.
So this read, write mutex
is another powerful data structure for synchronization.
But the read, write mutex is a little bit slower
than your regular mutex,
so if you truly need pure synchronization
for everything, you gotta use this.
If you can have multiple reads happening
while there's no writing,
then you wanna use that read, write mutex.
So we've got that atomic instruction, right?
Where we're dealing with data,
32 to 64 bits, great for counters.
You could do some level of signaling with that, too,
if you really need high performance signaling.
And you've got your mutexes to make lines of code atomic,
and you can use the same mutex across an API set, too.
So you can have multiple instruction sets
across a program using the same mutex.
But remember, mutexes create latency,
latency is death,
so we've gotta really make sure
that we're doing less than more inside those mutexes.