- So now that we've reviewed
how the operating system schedules work,
and the Go schedules work, and how it sits on top.
We now want to start learning how we,
manage concurrency in Go.
But I want you to understand something here,
Go routines are chaos.
Look, I've got five kids.
When I tell people I have five kids,
usually what happens is the person bends over and goes,
oh my god, oh my god, you've got five kids?
I've got one, how do you do that?
And I always say, I just travel a lot and get out of town.
Right, I just get out of town.
Well you can't get out of town,
when you're dealing with managing concurrency
and multi-threaded software in Go.
You're in town, you're in the house.
You've got to deal with the chaos.
I always like to think of Go routines as children.
If you don't have children yourself,
I'm sure you've got nieces and nephews, right?
And you know that children are chaos.
Like, one's running out to the pool,
one's running up the stairs,
one's running across the street,
one's about to pull off their diaper.
Which one are you going after?
Like, I'm going after the diaper,
man, I know that is a big mess.
But the reality is you only get one.
You only get to go after one of those kids,
when they're running in parallel, right,
and there is only one of you.
So, really what we're gonna be talking about,
for the rest of this section,
is really, what I always say, a parenting class.
I've gotta teach you how to manage your children.
Every time you bring a Go routine into your program,
you're bringing a child into this world.
This is now parenting 101.
I've got to teach you how to keep these children safe,
I've got to teach you how to keep
the house from burning down,
because multi-threaded software development is complex.
Now I told you there's two aspects to this,
there is synchronization and there is orchestration,
and there's tons of rules around managing concurrency.
Like this one, you're not allowed to create
a Go routine, or bring a child into this world,
unless you know how and when.
Right, you're not allowed to create a Go routine,
unless you can tell me how and when
that Go routine is going to be terminated,
before the Go program shuts down.
My equivalent to that is,
you're not allowed to lock the house up at night,
until you know that those Go routines,
those children, are safely tucked away in their beds.
You've gotta be managing this.
You gotta have known control over anything that's going.
Synchronization, orchestration.
So what I'm gonna show you right now
is some basic orchestration using a weight group.
And to show you how we are going to like,
keep our programs running until we know that
the Go routines, or these paths of execution, are done.
This is basic orchestration using weight groups.
We'll also learn how to create Go routines.
After this though, we've got to learn about data races.
We've got to start learning about
synchronization and eventually orchestration,
because without it,
you're going to have some really nasty code,
complexity, bugs. (groans)
But here we are, let me do my best here to
try to get you where you need to be
so you can start really being successful here,
with a less is more, less complexity is more attitude.
So let's start with this program right here.
Now I, on my Mac here, I have an eight core machine
and I wanna run some of this code as single-threaded.
So what you're seeing on line 17,
is me calling the GOMAXPROCS function,
from the run-time package,
telling it to knock my Ps
from eight to one, single-threaded.
And it's capitalized because
it represents an environmental variable
that this can override.
We used to have to use this back in the day because,
prior to 1.5, your Go program only came up single-threaded,
or one P, regardless of the number of cores.
From 1.5 since, we match Ps to cores
that are identified on the host machine.
Okay, now what you're seeing next to line 23
in the main function, and again I apologize.
This is being done in a net,
so it happens before main.
In the main function here, on line 23,
what you see is the weight group.
In fact, we are creating a value of type weight groups,
set to its zero value, naming it the variable WG.
And we're calling wg.Add(2).
I don't want to see you calling Add(1),
Add(1), Add(1), , Add(1).
In other words, the weight group is a
synchronous counting semaphore.
We're gonna keep a count of all
the existing Go routines that we create.
So we know that we're not going to shut down
until that count goes back down to zero.
So I want to do Add one time
for the number of Go routines we're gonna create.
If you don't know how many Go routines you're gonna create,
stop, you're going down a very bad path
and we're not gonna call Add(1) as they get created.
We call Add one time, upfront.
There it is.
So now we know we're gonna be creating
two new Go routines in this program
and I don't want this program to end until
those Go routines are finished doing their work.
And this is where we get the wait call on line 42.
The wait call is a blocking call.
A weight group has three API's,
add, wait, and done.
Add is adding to the counting semaphore,
done is decrementing, and wait will block until
the count goes from two to zero.
Again, we don't want main returning
until we know those other Go routines are done.
What you're seeing now on line 29 and 35
is the declaration of a literal function,
then the execution of that literal function,
remember literal means unnamed,
these are unnamed functions,
and then the use of the keyword go
in front of the function call.
This is essentially going to define,
call, and then execute, this function as a Go routine.
A separate path of execution.
I also want you to notice we're using closures.
I like closures, I think it helps simplify
how these literal functions access data.
You can pass it in,
we're gonna have to do that at some point today.
Closure bugs can be nasty, but the golint tool,
which you should be using,
can detect closure bugs, and you'll be okay.
So what these two Go routines do is call a named function
called lowercase and uppercase.
These functions aren't doing anything special.
They're just displaying the alphabet in lowercase
or uppercase letters three times.
Look at this full structure of this Go program.
Orchestration with the weight group,
we're gonna create two Go routines.
Go ahead, two Go routines.
Go ahead and create the first Go routine.
Have it execute lowercase and through our closures
call done to decrement the weight from two to one.
Then you run uppercase, decrement the weight groups
from one to zero, and you wait here until
those Go routines report they're done
by decrementing the weight group.
Understand when all things are equal,
we do not know what the schedule is going to do.
There is no predictability on whether
this function is gonna execute first,
or this function is gonna execute first.
They're both gonna be, essentially,
created within a very narrow set
of nanoseconds of time here.
So by the time the main Go routine goes wait,
okay, which is gonna force a context switch,
we don't know what the schedule is gonna do.
Whether it's lowercase or uppercase.
Alright, let's run this program.
I'm gonna go get the path of this program from the repo.
I'm gonna come over to the terminal window,
I'm gonna call go build, then I'm gonna run it.
What I want you to notice
is that the second Go routine that we created,
this one here which runs uppercase,
the second one we created,
is the one that ran first.
So you can see that this is a concurrent program.
It didn't run in the order that we put things in,
the schedule turned around and did that.
Now I could run this a million times,
and maybe it runs the same, maybe it doesn't.
Again, when all things are equal,
the scheduler is going to look and feel preemptive.
It's undeterministic.
Okay, great, so we've seen this.
But what happens if I turn around
and I forget to call weight?
Look, I forget to call weight, I pull it out.
Let's see what happens here.
Go build, run it, there's no output.
Start, waiting, terminating.
What happened?
Well this time, when I pulled the wait call out,
the main Go routine got to finish its time slice,
which meant main returning,
which meant this program terminated.
These two paths of execution that we created
never got a chance to run.
This is almost a race condition too,
because we're racing to see if this program ends,
before this scheduler makes a decision.
Now, I kinda understand that
orchestration and synchronization
must be guaranteed, or it doesn't work.
It might appear you program is working,
but without the guarantee, real guarantees
of organization and synchronization,
you're just getting lucky.
I want to show you this idea of luck
by using a function called Gosched.
No I don't want you using the Gosched runtime function
in your production code.
This, however, can be brilliant when running,
or building, tests that have to create chaos
because what Gosched does,
is it makes a cooperating request.
It is telling the scheduler,
I am willing to give up my time,
on the M, but the scheduler doesn't have to listen.
The scheduler can do what ever it wants.
This is a request, this is not a demand.
Wait was a demand.
Wait said I want to wait here, I demand that we wait here,
until this semaphore count goes to zero.
Gosched is a request.
I request that you let some other path of execution run.
But it's not a demand.
Let's see what happens when I add it.
It looks like the program worked.
It looks like our request to
run the other Go routines were taken.
But understand something, there's no guarantees here.
The scheduler, I could run this program 1000 times,
and the scheduler can make any decision it wants,
at any given time.
So don't think this program worked,
because it didn't, we got lucky.
This is why multi-threaded
software development is complicated.
We need the ability to know when we need to demand
and when we don't need to demand
and demanding comes form synchronization and orchestration.
Right now there is no orchestration going on here,
this is purely a request.
However, again, we can create a huge amount of chaos,
in our Go tests, by causing the Go scheduler to just,
in our kind of requesting way,
randomly make context switches.
Don't do this, there's no guarantees here.
That was bad code.
Let's go back to this.
Now, what happens if I forget to call done?
This happens in Go programs, right?
I forget to call done, I forget this program,
this Go routine forgets to report that it's done.
I don't know, let's build it again.
Let's run it.
Notice this time that I have a dead lock situation.
Yes I have a dead lock,
and the stat trace is gonna tell us
that our dead lock is on line,
let me bring this out a little bit here, on line 42.
Let's go look on line 42.
The dead lock is on the weight, it's absolutely right.
The weight group can no longer get to zero
because we are no longer calling done.
Now the Go runtime has a very simple dead lock detector.
It's simple because all it can identify is
when every single Go routine is now in a waiting state
and can never move back into a runnable state.
If you have one Go routine that can continue to run,
we walk away from this dead lock detection.
So we wanna try hard not to create Go routines
in our services that kinda spin on a timer.
You're walking away from some of this dead lock detection.
Yes, it is simple dead lock detection,
but it is there.
You're seeing it, this is bad.
The same thing can happen
if we don't set the weight count appropriately.
I mean look at this,
let's say I set the weight count to one.
That means as soon as one of these Go routines is finished,
the weight group will go to zero.
Again, we're gonna have some chaos,
because we're not really managing concurrency.
We're not waiting for both Go routines to finish.
Look, the Go routine doing capital letters
just finished and we don't see the next one run.
This is all bad races, this is not proper orchestration.
Synchronization, orchestration, is about demands,
around guarantees, that things happen in the order we need
or we're waiting for things to finish before we move on.
So the weight group is a great way of doing orchestration
when we don't need anything back from the Go routine,
we just need to maintain the semaphore count.
Now, let's add a little more complexity
to this piece of code here.
What I'm gonna do is spell example, right.
What I'm gonna do is come in here
and look at this next example.
Okay, brilliant, example two.
Now, we're gonna take the base pattern again.
of weight group orchestration.
Notice I'm knocking my Ps down to one,
I've got my weight group, we Add(2),
weight group orchestration here.
I've got my two Go routines being called
and run as an independent path of execution
and then on line 42, we wait, brilliant.
But this time, we're gonna do a little bit more work.
This time we're calling printPrime.
And the printPrime function identifies prime numbers
from two to 5,000 and displays them on the screen.
There are lots of system calls going on here
and this work is gonna take
a lot longer than the other work.
What's nice about this program is
we're going to be able to see context switches
and see that we're not going to be able to predict
when the context switch is going to happen.
So, let's build this program.
Here it is, I build it, I run it.
There's a bunch of output.
I'm gonna go to the top
and you can see the second Go routine started again.
That's, from my perspective, random.
And what I'm doing is looking to see when
a context switch happens.
And it looks like B is getting a really good run here,
and I'm gonna keep, oh, there it is.
Okay, so we now have a context switch
after the A Go routine got to prime number 3,833.
Let's write the right one here, 3,833.
Then the B Go routine now contexts to A.
Now if I keep scrolling, what do we see,
what do we see, what do we see.
Does the A Go routine get to finish?
Nope it doesn't, there it is.
There's another context switch.
A went to, what is that again right there,
3041, 3041, there's a context switch.
Then B picks up right where it left off.
B finishes, and then A finishes.
So then B finishes, and A finishes.
So we did see some context switches.
What I want to really show you is
that these context switches are not predictable.
I'm gonna run this again and its not gonna be 3,833.
In fact, I have no idea what it's going to be.
It's really not possible to predict
the output of this program.
So, let's run it again.
Let's scroll to the top.
B started again, that's fine, and here's our context switch.
And where did it happen?
It happened at 3,691.
So this time it happened at 3,691.
Again, I couldn't even predict that
and if I just look to see if A gets to finish or not.
Look at we're doing here, oh, there it is right there again.
I see that we're at 4,159.
4,159, look, A got a much larger run on this run here.
I imagine that it's gonna finish,
it finishes, it finishes, okay.
My point here is that
even though this is a cooperating scheduler,
because the schedule is doing the cooperating and not us,
it looks and feels preemptive.
When all things are equal, it is undeterministic to know
when the scheduler is going to make a scheduling decision.
I just showed you that with this piece of code.
I have no idea if I even run it again,
where this is gonna happen.
And I've run this before
where I even see another set of
context switches before it finishes.
But let's now bring this completely full circle
and go to one more example.
This time, let's go back to the original program we had
but just make two minor modifications.
One, lets run this now in parallel.
Let's use two Ps, which means two threads.
At this point now,
what we're gonna have is not just one P
per thread, but two Ps, P one,
there it is, M, P two, M,
and then since I'm on a multi-core machine here,
these two Ms are going to be able to run in parallel.
In fact, our Go routines
are gonna be able to run in parallel.
So, maxprocs two, weight group orchestration,
we're gonna create two Go routines,
there it is, I'm using a full literal function
this time to do all of the work.
There it is, do the alphabet in lowercase three times,
then report that we're done.
Second Go routine, do everything in uppercase letters,
then report that we're done and then we have the weight.
So in this case, these two Go routines,
they're gonna be running in parallel, making system calls,
which means the operating system
is going to be handling synchronization on the system calls.
We should see a mix of output, when I run this program,
which we didn't see before.
If I run this enough times, we should see,
I'm gonna look down here, you should see here
that we now have a mix of output.
This one's a really nice one, we see a good mix
of output, right here on this line.
You can see lowercase, uppercase, lowercase, uppercase.
They're running in parallel now.
This has almost nothing to do with
the Go schedule, at this point.
It's the operating system allowing
these threads to run in parallel
and their system calls are now being synchronized.
So, you can see here that now
that we've went from one P, or thread, to two,
we're now a multi-threaded Go program.
Go routines can run in parallel
and this is where synchronization, orchestration
really, really become important.
See, Go made it super simple,
to create these paths of executions, or Go routines,
but you still have the burden
and no language can take care of this,
no runtime can take care of this.
You still have the burden of
synchronization and orchestration.
I've shown you weight group orchestration.
So, the next thing I really have to show you is
how to deal with synchronization in the language.