Master Go Programming With These Concurrency Patterns (in 40 minutes)

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
alright so go is a very interesting language and by interesting i mean that when wielding this language you're given the opportunity to effortlessly spin off an army of concurrent functions with just a few lines of code but as the old saying goes with great power comes great responsibility and that is to say that although syntactically firing off these little guys can seem effortless the reality is that delusion can result in some very buggy and difficult to maintain code so in this video i'm going to try to quickly explain all of the necessary go concurrency patterns that you can use to write performant and maintainable code and in this video we're not going to get into too much of the theory behind concurrency that's something that i'm kind of going in this expecting you to understand this video is going to be a more hands-on approach to learning concurrency patterns and go we're going to start with the primitives that are used to actually write concurrent code and go and from there we're going to build concurrency patterns using those primitives that we learn and with that in mind let's just go ahead and jump in and get into go's concurrency primitives and there are three that you're going to need to understand and be able to will to make use of these patterns they are go routines channels and select so we're going to start with go routines so what is a go routine [Music] okay so we're just going to start here by creating the main function to our program so we'll just do funk main and this is going to be the entry point to our program so when our program is run this function is it's going to be run and within this function we're just gonna do format print line and we'll just print hi and we'll just save that and go into our terminal and run the program and as you can see we get high printed to the screen so aside from our main function we can also create another function and we'll just call it sumfunk and this function is going to take in a number as a string and this function will just print that number and in order to run this function we can call it from within our main function because remember the main function is the entry point to our program so we can just do some funk and we can pass in a number as a string and actually let's just go ahead and do one and we can save that and we can run it and as you can see we get one printed and then we get high printed to our terminal so here when we're calling this function from within main this function's being run synchronously and that means that our main function is going to be blocked until the completion of this function so this format print line here won't run until after some funk is finished running but with go routines we can actually make some funk fork off of our main function asynchronously and that would mean that main would not need to wait for some funk to finish before it could continue with its work which in this case would be to go and print hi and we do that simply by just putting the go keyword in front of the function call so this here is a go routine so when we run the program mains going to run and it's going to fork this function off of its own process which essentially means that this function is going to be spawned and it's just going to go and run and mains going to go back to what it was doing it's not going to wait for this function and that gives us the power to run functions concurrently because we could do multiple of these and main would just go fork this off fork this off and then fork this last one off and go and continue with what it's doing so for example if we save this and we go into our terminal and we run the program again you see that hi gets printed but the results of some funk the the above three calls to some funk don't get printed and that's because mains basically these processes are just forking off of main and they're going to run or they're going off to do whatever work is defined within the function and once maine just sends those off to do what they need to do maine is going to go back to what it was doing it's not going to wait for them it's not synchronous it's asynchronous so we're asynchronously calling these functions so with that in mind how do we actually see the results to these functions because they're in no way synced with main like they never rejoin main like we can't see the results for them right so let's just go ahead and change this to two and change this to three we can actually just do time dot sleep and we'll just do time dot second times two and this is going to make main wait for two seconds once it gets to this line before going to perform this code here and two seconds should be enough for these three go routines to finish running so if we save this and we go back into our terminal and do go run primitives you can see that in no particular order we get the three numbers printed to our screen and the reason that they're not in any particular order is because remember these are just asynchronous they're not synchronous so this one doesn't wait for the completion of this one it's like basically whoever prints first is what gets printed to the screen first because they're all running concurrently right so these are go routines and this is one of the most essential building blocks to concurrency and go so let's briefly go over how concurrency and go is structured you'll recall that our main function is the entry point to our program it's essentially the parent function to any other function called within the program and you'll also recall that these go routines fork off of the main function so what does that actually mean well let's have a look at a diagram so go follows a model of concurrency called the fork join model which can be visualized using this diagram so this diagram represents the life cycle of our main function so here would be the start of our main function and here would be the end of our main function so the main function starts here and ends here and of course this main function represents this and actually we can just say this is the start and this is the end of the main function so child processes or go routines are forked off of the main function the main function will fork a child process off of it and it will continue to run just like we explained here so this fork child here is actually representative of one of these go routines being forked off of the main function but with this fork joint model we see that our child process is forked off of the main function but we also see that at some point that child rejoins the main function and this point is called the join point and actually we're responsible for implementing the rejoining of the child process to the main process for example as you saw before when we didn't have this time dot sleep line of code here we actually were never able to see the results of these go routines because maine basically just shot these off and forgot about them and then it went and finished its own process and we never got the results from this and just so that there's no confusion this time dot sleep actually isn't representative of the type of code that is necessary to create a point at which the child can rejoin the main process or the join point because this time dot sleep doesn't actually sync our main process with these concurrent go routines for example if we were to sleep for maybe 100 milliseconds maybe these still wouldn't have time to finish that is to say that with our current code our main function doesn't coordinate in any way with these go routines which means that our main function is technically not synced with these go routines so we technically don't necessarily have a join point yet but will be responsible for implementing that later on down the line when we get further into the explanation okay so now that we've gotten go routines out of the way let's move into our next primitive which is channels but before we get into channels i feel that it's imperative that i inform you that it's in your best interest to like and subscribe to this channel i know that that's a very bold statement to make but i don't think that you'll be disappointed and if you are you can just unsubscribe but anyways let's get into channels so what are channels so channels are typically used to communicate information between go routines because remember our go routines are running independently of one another and don't require knowledge of each other so in order for our go routines to communicate we can have them reference the same place in memory which is where a channel would reside for example let's say we have some go routines and they're running along doing their thing and let's say that go routine 3 needs to communicate with go routine 2. it needs to send data to go routine 2. in that case we could have a channel here that go routine 2 will read from and go routine 3 will write to and we can really just think of channels as first in first out cues so let's say that the data that's being communicated to channel 2 from channel 3 are hearts so three units of data are put onto the channel by go routine three girl routine two would just read off those units of data in first in first out order so this one would get read off first and this one second and then this one third and remember our main function is a go routine as well so that means that our main go routine can communicate with its child's go routines through channels as well for example if we had another channel that our main function reads from and all of the child's go routines right to in this way you can kind of see how we will be able to implement the before mention join point from the explanation of the fork join model so how do we actually initialize and make use of these channels well it's quite simple really so if we want to initialize a channel we can just do something like my channel equal to the result of the make function and we're going to use channel and we want the data that's passed through this channel to be of type string and now we have our channel so at this point we could make a go routine and we can just use an anonymous function here so this go routine is going to send data to the channel so we'll do an arrow pointing to the channel and next to that we'll put the data that it's sending onto the channel and since this is an anonymous function or a nameless function we need to invoke it now so this is the syntax to send data to a channel and from there we can have our main function read from the channel so we'll just call the result message and the main function is going to take in the data from my channel so the main function is reading the data from my channel and after that we can just print out the message which should be data and let's go ahead and save that and just go ahead and open up our terminal and go run primitives.go and as you can see our main go routine actually waits for the message and that's because this line of code here is actually blocking that is to say that the main function will actually wait for either this channel to close or for a message to be received from this channel so you'll recall that during the previous explanation of the fork join model we mentioned that we need to implement the join point or the point at which the forked go routine rejoins the main go routine and when writing this code we actually implemented that so if you have a look at the code within our go routine function here we see that we're putting this data onto our channel this arrow means we're putting the data onto the channel and remember both our main go routine or our main function and this co routine or this function communicate via this channel that we created so the go routine is putting the data onto the channel via this arrow and our main function is reading the data from the channel so this is the channel the data is going from the channel to this message variable so at this point both our main go routine and this go routine that is spawned by maine which would fork off and go do something here is rejoining our main go routine via this shared channel so both our main go routine and this go routine are synced at this point because this line of code here is a blocking line of code this means that our main function is actually going to wait for the data to be put onto this channel to receive it here and then at that point that's when we print this message that we receive from the channel so at this point we have a very simple example of a join point or a join point implementation so that and the syntax for putting data onto the channel and to receive data from the channel are important things to note here okay so now that we've gotten channels out of the way we can move into the last primitive which is the select statement at this point you should be starting to understand how all of these are going to come together to help us to form the patterns but yeah what is the select statement so looking back at the code that we wrote for our channel example we can just add the implementation of the select statement so essentially what a select statement does is it lets a go routine wait on multiple communication operations and in this case we can say that it's going to let our main function wait on messages from multiple channels so let's go ahead and create another channel and we'll just change the name of this channel to another channel and this channel like the previous channel will be a channel that has strings as its values and we'll go ahead and create another go routine so let's just copy that one and this go routine is going to put data onto another channel as opposed to my channel like this go routine above and we'll just change the data here and now down here is where we're going to want to do our select statement so currently we're only receiving data from my channel so we can just delete that and delete that print statement as well and we'll just type in select and this is our select statement and we're going to have case message from my channel and this case will be for messages that come from the my channel channel so my channel and if that is the case we're going to print message from my channel and we'll have one more case here and this case is going to be message from another channel and this is going to be receiving messages from another channel and in that case we're going to print message from another channel and that is our select statement so a select statement is going to block until one of its cases can run so in this select statement we have two cases but we could also have more for example but in this example we're only going to have two so the first case is if it receives a message from my channel and the second case is if it receives a message from another channel and it's going to block until it receives a message from one of these channels and once it receives a message from a channel it's going to execute the code within that block if the select is able to receive messages from multiple channels at the same time it's actually going to choose one at random so if multiple messages are ready it'll choose one at random and that's basically it that's what the select statement does so now that we know what the select statement does we can go ahead and open up our terminal and test it out so we'll just do go run primitives.go and you'll see that we get one of the messages the message that goes on to the channel named another channel but remember that if both channels have messages ready at the same time one is chosen at random and of course if that's not the case then one is ready before the other so if we run this a couple of times we should be able to get the other message as well at some point and there it is there and that is the select primitive okay so we're finally here and if you've made it this far congratulations we're now going to get into using the concurrency primitives that we learned to build concurrency patterns and we're going to go over three patterns that i think are essential to writing concurrent code and go and you can actually build upon your understanding of these patterns to learn other concurrency patterns because of course there are more concurrency patterns out there and there's a good chance that there will be more to come and with that being said there's actually a really good book on concurrency and go that deep dives into other concurrency patterns that i will link in the description and let's just get started with those three concurrency patterns that i think are essential so the first one is going to be the four select loop so that's the one that we're going to start with and after the four select loop we're going to move into the done channel and once we've gained an understanding of the done channel we're going to move into understanding pipelines so let's get started with the for select loop okay so let's imagine a scenario where we have an array of characters that we want to put onto a channel so we want to put each individual character onto this channel here this is a simple example of where we would use a for select loop and a four select loop is essentially just using a select statement for each iteration of a for loop for example if we were looping over the character array or actually slice and you'll have to excuse me i'll probably make the mistake of calling a slice in a ray but it's just because go's not the only programming language that i work with but anyways let's imagine a scenario where we're iterating over this slice and for each iteration we're using the select statement within the loop so as demonstrated here for each character in this character array we're going to use a select statement here and we're basically just going to put the character on the channel and it's really as simple as that for each iteration we're just going to put the character on the channel and we'll end up with this channel containing these characters okay so let's have a look at what that looks like in code so we'll start by creating a buffered channel and by buffered i mean that this channel is going to have a limited capacity of three and we actually won't need a corresponding receiver for these three values which is in contrast to the default unbuffered channel which is essentially used to perform synchronous communication between go routines so in the past examples we were using unbuffered channels so the default if we were to not put a capacity here would be an unbuffered channel for example if we go to the documentation for make and we go to the documentation for channel we see that the channel's buffer is initialized with the specified buffer capacity which is the capacity that we're specifying when we're creating our buffered channel in the most recent example and it also says if zero or the size is omitted which we were doing in the past examples when making channels the channel is unbuffered so in order for us to make communication between go routines asynchronous we need to actually use a buffered channel so we're going to set the capacity to three and you might be wondering why the unbuffered channel resulted in synchronous communication between go routines as opposed to asynchronous and that's because an unbuffered channel provides a guarantee that an exchange between two go routines is performed at the instant the send and receive takes place but with the buffered channel we're actually using the cue-like functionality where we basically can send data onto the channel and just forget about it so that means that the sending go routine can just send and forget up to the allotted capacity of course so let's imagine that we created an unbuffered channel by not passing a capacity as a parameter when we created the channel using make and let's also imagine that we have a sending go routine that wants to send some data onto this unbuffered channel when this go routine sends the data onto the channel this go routine will go into a waiting state so that means that this go routine will be blocked and it will be blocked until there's a receiver receiving the data from this unbuffered channel now let's imagine that we have a line of code for a receiving go routine to receive the data from this channel once we reach this line of code and this receiving go routine receives the data that was sent by this sending go routine at that point this go routine will no longer be blocked and that's what i mean when i say that an unbuffered channel only allows go routines to communicate synchronously because synchronous communication is when the sender needs to await the response from the receiver so the sender is blocked until there's a response from the receiver and in this case the response comes through the channel so you can think of it as the channel informing the sender that the receiver has received the data and that's what allows the sending go routine to leave that waiting state now with a buffered channel we're passing in a capacity so in this case we're creating this channel my channel with a capacity of three in this case communication between go routines is asynchronous so let's imagine that we have a go routine here and this go routine is a sending go routine and once again we'll send some data onto the channel with a buffered channel this go routine isn't blocked it will send the data onto the channel and that data essentially will just get queued and this go routine can just continue with what it was doing but that's only if we haven't yet reached the capacity so if this channel were actually full if it were actually at capacity and this sending go routine tried to put more data onto the channel at that point this go routine would block until data's read from this channel but up until the capacity the sending go routine can put data onto this channel and continue with what it was doing and if you notice this go routine is able to continue with what it was doing and we don't even yet have a receiving go routine but this go routine doesn't need to worry about that because the communication between go routines with a buffered channel is asynchronous that means the sending go routine can just send and forget so we can imagine that we have a receiving go routine and of course this go routine can receive the data but this go routine isn't blocked in the first place so the communication between this sending go routine and this receiving go routine is asynchronous so we'll also create a slice of chars and it'll be string and we'll just put abc and all we're going to do is loop over that slice of characters and for each character we're going to put it onto our char channel and we're going to do that using the select statement and we'll just do case our channel and after we iterate over all of the characters in our slice and put them on the channel we'll go ahead and close our channel and at that point we'd be able to loop over the result so we can just say result equals and we'll just print the result and this code here actually tells us something this tells us that we are actually able to loop over a closed channel and still receive the residual data that was put onto the channel because here we're closing the channel and then here we're looping over the data that's in the channel and this loop will end because internally it knows that the channel was closed at a certain point so if we go ahead and save this and open up our terminal and do go run primitives.go you see that we get abc printed from our channel and you're probably wondering why even use this select statement here so for example why not just do char channel and then just put the data on like that and that's actually a good question and we're actually going to get into that when we get into the next pattern so just bear with me for now and know that we will get back to that later so now let's go ahead and get into another example of our four select loop pattern so let's suppose that we had a go routine that we want to run indefinitely or at least until its parent go routine tells it to stop so if we do go funk and we just do 4 and 4 is just going to be an infinite loop and then we do select we can use this default to just do some work and here we have an example of an infinite looping go routine so if we just do time dot sleep here and we do time dot second and save and go ahead and run this you'll see that it's just going to infinitely print do work well at least until 10 seconds is up because the parent go routine which is the main function is actually going to just cut off after 10 seconds but this is an example of the four select pattern being used to have an infinitely running go routine but you're probably wondering how this is even useful like there's no way for us to stop it or anything like that and that's when the done channel comes into the picture which is our next pattern so let's go ahead and get into the done channel okay so in order to understand the done channel we should understand what a go routine leak is now of course there may be a case where you want a go routine to run for the lifetime of the application but what we want to prevent is unintentionally leaving go routines running for the lifetime of the application and in order to put that into perspective let's imagine that this application is actually a production long-lived application so if you imagine a real world application with actual users you don't imagine an application that only runs for 10 seconds so to emulate this type of long-lived application we can just change this to hour and we'll just put some arbitrary number of hours there and after we could just go ahead and save and then go into our terminal and go run and you'll see that our infinitely running go routine is doing work right but let's say for example we have a go routine running that we don't want to run for the lifetime of the application and we don't actually realize that that go routine is running in the background and consuming memory and processing power and resources that is an example of a go routine leak and that's something that we want to avoid when we're actually writing our code and one way that we can deal with that is by implementing some sort of mechanism that will allow the parent go routine to cancel its children and that's where the done channel can help us in this situation so let's go ahead and cancel that so let's say for example that we want our main go routine or our main function to cancel our infinitely running go routine after a couple of seconds so in this case we could just create a channel that we'll call done and we'll just do make channel and it'll be a bull and in this go funk we can just pass in the done channel as a read only channel and the way that we do that is we just use the receive arrow and channel as the type and then of course we need the data type for the channel and let's go ahead and just change this to do work and we'll change this to funk and we'll remove that and let's go ahead and cut this and put it outside of our main function so now we have this do work function that's going to be a go routine and we'll just do go do work and we're going to pass in the done channel now if we go back to the definition for our do work function now we can make use of this done channel and that's where the reasoning behind this select in our for select pattern comes in because we want our go routine to do work by default but if the parent cancels this go routine we want that to be a case as well and what i mean by that is we can just do case and if we've received from the done channel we can just return so that means that by default this go routine will continue to do whatever work that it needs to do and the parent go routine will have the power to stop this go routine from doing work when it deems necessary so if we go back down here our parent can say that it wants this go routine to continue to do work for let's say three seconds and then after that all the parent go routine has to do is close the done channel and when this done channel gets closed this do work function will receive a case done it'll receive a message essentially from the done channel which will cause this do work function to return and stop the go routine so let's go ahead and try that out [Music] and as you can see after three seconds the parent go routine cancelled the child's go routine from doing work by using the done channel by canceling the done channel and a couple of things to note here we're passing this done channel to this do work function as a read only channel and that's what this syntax is doing here so this do work function can't actually write to this channel it can only read from it but as you can see if we were to remove this select we wouldn't be able to have this case where we checked the done channel to see if the parent wants us to cancel this go routine and that's one of the reasons why the for select pattern is so common but yeah that is going to be the done channel okay so we have finally arrived and dare i say that this is the moment of truth this is the moment that we learned the final concurrency pattern in this concurrency pattern video the pipeline so if you made it this far congratulations and yeah saddle up and get ready for takeoff okay so let's start by first going over what a pipeline actually is just in a general sense a pipeline is nothing more than a series of things that take in data perform an operation on that data and pass it back out so the data goes into this thing here does an operation on it and passes the data back out and that data then gets passed into this thing here does an operation on the data and passes the data back out and we call each of these things a stage in the pipeline and all of these stages working together form the pipeline and by using pipelines we can separate the concerns of each stage for example say that the data that we pass into the pipeline is a slice of integers and let's imagine that we want to do something random with each value in this slice like we want to square each value in this slice and we want to convert each resulting value to a string instead of having one stage that does all of that we can separate the concerns which in turn will make our code more modular and more organized so we can have one stage to actually square the number and we can have another stage to convert to string and in this way we can modify stages independent of one another we can mix and match how the stages are combined independent of modifying the stages etc so separating the concerns is just something that's beneficial overall so now that we understand what a pipeline is in a general sense let's get into understanding how that applies to our concurrency patterns so the start and the end of our pipeline is going to be orchestrated via our main function like so so if we have an input here and we'll just call the input nums that input is going to be passed to the first stage of our pipeline so we'll do stage 1 and the output for stage 1 is going to be a channel and we'll call it data channel and we'll set it equal to the function that will be responsible for the logic for that particular stage so the first stage we're going to convert this num slice into a channel or we're going to put each element from this num slice onto a channel and the resulting data is going to be this data channel so we'll call it slice to channel and we're just going to pass in nums as the value so we'll create that in just a second but now we'll move on to stage two and for stage two we want to pass the output from stage 1 into another stage so this sq function is going to be the stage that squares each number from the original num slice and this stage is also going to output a channel so we'll just call this one final channel and again we still need to create this function and last but not least for stage 3 we need to output the result of the entire pipeline so main is actually just going to be responsible for stage three so we'll just do for n equals range final channel format dot print line in so this is going to print the data that's been processed this numbs array is processed by going through these channels and then we're going to print it here so now we can go and create our actual stage code so for stage one we're converting our slice of nums into a channel so we'll just call this funk slice to channel and it's going to take in a slice of int and it's going to return a read only channel and of course we need to add in for that as well and this stage we're going to create a channel and it's going to be an unbuffered channel so keep that in mind and then we'll just spawn a go routine and for each item in that slice we'll add it to the out channel so we'll do n equals range nums and we'll put in on to the channel and this is an anonymous function so we need to invoke and once the for loop is finished we're going to close the out channel and we'll return out now remember what we learned in the beginning of this video this go routine is asynchronous so we're going to spawn this go routine here but slice to channel is not going to wait for the go routine it's just going to return the channel and i'll explain that further in a little bit so now we're going to create our square function and it's going to take in a read only channel which we'll call in and it's going to return a read-only channel as well and here we'll also set an out channel which will be int and it's going to be an unbuffered channel and here we'll also spawn a go routine but for this function we're going to range over the in channel which is going to be the channel that is returned from the slice to channel function and we'll write to our out channel the square value of each number from our in channel and once this loop is done we're also going to close the out channel and then we will return out so once again please keep in mind that this go funk isn't going to block the rest of this function we're going to send off this go routine and then we're just going to return out and that means that potentially this go routine could still be sending values to the out channel even at the time that we return it so let's take a second to try to visualize what's happening here let's try to visualize our coded pipeline in a similar diagram to the original explanation of pipelines so we're going to have a start and an into our pipeline and this here is going to be our input and the pipeline will have stage 1 stage 2 and stage 3 and this will be our data so in each stage we're going to do something in particular right so this first stage we're going to do this slice to channel so we're going to convert this slice into a channel and the resulting channel will be here this outputted data will be outputted as a channel so this is actually this and this is actually this and here at stage three we're actually printing out each individual piece of data so this would actually be multiple individual pieces of data being printed out so with this you can clearly visualize how this is representative of a pipeline but the difficult part to understand is the actual code within this slice to channel and within this square function so here we have both our slice to channel and our square functions and the returned channel from this function is what we're passing into our square function and as mentioned before this go routine here doesn't block this slice to channel in slice to channel we're creating this out channel we're shooting off the score routine and we're returning the channel and then that channel gets passed into this square function and at that point we're creating a channel here as well an out channel then we're firing off this go routine and then we're returning the out channel which is later on printed by our main function but all of this communication between these go routines is happening synchronously because remember we're not using buffered channels we're using unbuffered channels so that means that this go routine and this go routine are communicating synchronously and remember an unbuffered channel has a capacity of one like we can't send more than one value to this unbuffered channel so what's actually happening here so we're firing off this go routine and then we're returning the channel so i want you to imagine that both disco routine and disco routine are running at the same time because that's technically what's happening here so this go routine is iterating over the input slice of nums and when it puts one of these numbers onto the out channel it actually blocks until that number is read from the out channel so we return this channel while this go routine is still running and this function has that channel and this go routine is reading from that out channel from here so for each iteration of this nums slice this go function actually blocks until whatever value that got put on this out channel is read by this go routine so this go routine is writing to this out channel this go routine is reading from that out channel here so this go routine puts a value onto the out channel and then blocks and then this go routine reads that value from the out channel and then it blocks until there's another value on this channel and then this go routine puts another value on the out channel and then it blocks and then this girl routine reads another value from the out channel and then it blocks again and this go routine you probably would imagine that since an unbuffered channel only has one value we can't really range over it but that's not the case we can range over it just fine so what's going to happen is whenever there's a value put onto this channel since this channel can only have one value we'll read off of it and then this range will just wait until another value is put onto the channel and it'll keep doing that until this channel is closed which is why after we put all of the nums onto the channel we close this one because when we close it this range here is going to receive that close and it's going to know that this for loop can stop and then once that happens we know that we can close our channel here as well so that's what's happening between these two functions or these two stages in our pipeline and the same thing is happening with this out channel if we have a look at our main function we see that this square function is outputting its out channel in the name final channel and we see that stage 3 is ranging over final channel so the same way that the square's range was able to range over an unbuffered channel this range can also arrange over an unbuffered channel and it's doing the exact same thing that we were doing inside of this function it'll read off of the channel and then it'll wait for another value to be put on this channel and it'll do that until this channel is closed and you saw that within this function we closed this channel so you can see how all three of these stages are synchronized and we're processing each value and then printing it so this entire slice will go through our pipeline and end up being printed and when each individual value is printed it will be printed as the square of its original value and that is a relatively simple example of a pipeline so now we can just go ahead and test to see if our pipeline is working and as you can see we get the squared value for each item in our original int slice and you can also see that it's in order because the communication between our go routines is synchronous foreign
Info
Channel: Kantan Coding
Views: 167,671
Rating: undefined out of 5
Keywords:
Id: qyM8Pi1KiiM
Channel Id: undefined
Length: 46min 15sec (2775 seconds)
Published: Fri Jun 10 2022
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.