 In this class, we are going to talk about GoRoutines. GoRoutines can be considered lightweight threads that can run concurrently with other parts of the program. Think of them as individual tasks or jobs that can be performed simultaneously. The advantage of using GoRoutines is that they are extremely efficient and lightweight. You can create thousands of GoRoutines without a significant impact on memory usage or performance. They are managed by the GoRound time, which automatically schedules and distributes them across available processor cores. Let's start our first example here. I'm going to print a message in the console. Let's run our program and see the output. So right now, we have the message done. Instead of calling the println function, I'll be creating a new function that will receive a string parameter and will print it. Now, instead of calling println, I'm going to call my new function. So this will be the same result as before without changes. Okay, but let's say that we want to print more than one message like this. One, two, three, four. So running the application, we see the message is ordered and we will introduce the GoRoutine right now. So we are going to use this keyword Go and call the function after this. So this will be executing these print messages asynchronously. But when we execute the program, we don't see the output of the first four messages. And this is happening because this here is the main GoRoutine, our main thread. And this thread is not giving enough time to the other functions to be executed. So in order to hold the little, the main thread, we'll have to put her time slip. I'll be slipping for one second and we will see that this will be enough to execute the other GoRoutines. Now that we have enough time, we see that all the GoRoutines are printing in the console. Cool. I'll be increasing a little the number of messages here just to show you another thing. So let's iterate in this print message function five times. Okay. And let's execute this way. So one thing that's important to notice here is that we don't have order, the same order happening. Because the GoRoutines are executing in a synchronous way, we can't handle them in this example, we can't handle order. We don't know which one will be executed first or last. A good example of this would be creating a new GoRoutine in after the main print message. This is an onion balls function. So I will print something here, something else. And we are going to notice in the console that this will not be the last message being printed, but instead one of the firsts. So it's clear for us that we don't have much control on the order here, but we can execute a bunch of different stuff asynchronously. That's it for the GoRoutines class. I hope you enjoyed and see you in the next class. Amutix in Go is used to control access to a shared resource in concurrent programming. It prevents multiple GoRoutines to modify the shared data simultaneously. So right here we are going to create a problem. We are going to create a GoRoutine and we are going to change a variable inside of it. Let's increment that and let's print that. So the first problem here is that we are not going to see the correct value of the variable because this is happening because the GoRoutine didn't have time to finish before the program finishes. So let's wait a little in the main program and see whether the GoRoutine will have time or not to finish. Now we see that, yeah, it had time to finish before the main thread. Let's increment our problem. Let's create a huge problem here. I'm going to create 1000 GoRoutines and I will change the count variable in each GoRoutine. So now we have a different problem. We will notice that every time that we run the program we have, we got a different value. So we may think that we did the thread, we did not have time to finish but we are going to see that this is not a problem of waiting but let's print inside of the GoRoutine and see what is happening there. Let's print it again. So as you can notice, a lot of GoRoutines is changing the variable at the same time which is causing this behavior like here, a lot of 32 values. So we want to prevent that. We don't want the GoRoutines to change at the same time the count variable. So let's solve that using mutex. So we have this variable now from the sync package and now we will lock and unlock in the end of the function. That's it. We are going to notice that we will have order now. I will keep the slip. So now you can notice that we have order. Each GoRoutine is changing the variable in a different time. So we have 1000 GoRoutines changing the variable one at a time. That's what mutex does. It protects that specific piece of code, section of code. It protects this section and it assures that one GoRoutine is not racing against each other. That's it. That's it. In this class, we are going to learn about condition variables in Go. So they are used to coordinate the execution of GoRoutines based on certain conditions. So associated with mutex, they allow GoRoutines to wait for a condition to become true or to be not fired when a condition changes. It will be easier to understand from the code. So I will create two new functions here, one called producer and another one called consumer. We will need some variables here also. So let's create a counter and a mutex and we will also have the condition variable. So the condition variable uses this function called newCond from the sync package and in this function, we will pass the mutex as a pointer. So the condition variable, it works together with the mutex. For now, I will be commenting this so we will explain it later. So I implement the producer here. Let's keep it simple by now. Let's just increment the variable here and okay, counter and let's decrement the variable here. I will call both using the GoRoutine and I will wait for some seconds. So let's see how it's going to behave. You know what? Let's add some time slip here in the producer and in the consumer so we can see it easier. It's going to be easier to see that in the console. Okay, now we are waiting one second and printing the variable. Yeah, we don't expect that to work. We will see a lot of a mess in the console. So let's try to make it clear in the messages so we can see which function it's changing the variable. So this is the incrementing and this other one is the decrementing. Okay, now let's run again. Yeah, so now we see that's not working. We see that's incrementing at first then decrementing twice and then incrementing twice so it's not working. Okay, we are not using mutex here. Let's use mutex and see if we can see a better results. So you know the deal here is we have to lock and unlock the section of code so we can assure that each function will change the variable counter at a time. Yeah, now we have a better result but still seeing twice decrementing, twice incrementing and not any order. So because mutex is not for that, mutex is only to protect the resources when the functions are changing them so we can assure that they are not changing at the same time. But we can add some logic using the condition variable and make sure that we have an order. So in the producer I will add an F condition here and when the counter it's greater than zero I will be waiting. So this is the function that we use to hold and I will do the opposite here in the consumer. So when it's equals zero, yes, I will be waiting. We also have to add a signal function using the account variable and what it does it sends a signal to the waiting condition variable so it's gonna release, it's gonna stop waiting. Let's run. Okay, it looks like it worked. Perfect, let's run again so we can assure it. Yeah, perfect. Let's do a little review on what is working here. So we have the producer and the consumer being called and the counter it's zero when it starts. So as it's zero it's not gonna to attend this condition so it's gonna be incremented at first but in the consumer it's gonna be waiting because it is zero so this waiting is happening. So after the change the producer will unlock the thread and the consumer will have the signal, will receive the signal and will release this part of the code so the counter will be decreased. As it will be decreased we are going to release and then the producer that was waiting will now increase again the variable. So that's it, that's a good example on how to use condition variables with mutics and I hope you like it. See you next class. In this class we are going to talk about weight groups. In Go, a weight group is a synchronization mechanism provided by the sync package. It allows you to wait for a group of gorotins to finish their execution and then proceed. We are going to create a problem and after that we will solve it using weight groups. Let's go. So I'm gonna create a problem that we've seen a lot so far we have this count variable, some gorotins, a lot of them and we will change this variable inside of this gorotins. So let's make it 100 and then create gorotins inside of this loop. Let's print it. Now when running this I'm sure that we are going to receive zero because remember the main thread finished before the gorotin had time to finish. So we have to do something and what we usually do is that we create a time sleep and then let's give it a second to finish. Okay now we have this 90, 99 because we are not using mutics. I think that one second it's enough to finish but we have to use mutics to assure that they are changing the variable correctly without racing. Okay now we have 100 and that's it. But we have a problem. We are waiting one second so let's remove this time sleep here. What's gonna happen? We will see zero because we don't have time. So let's increase the far. Yeah it looks like it's working but let's increase it more. Now we have 10,000. It's too enough. Oh this is fast. Let's see the limit of one second. Okay, okay finally. So we got to the point that one second is not enough time to run 100,000 gorotins there. And what about five seconds? Yeah it's enough. So the problem that we create here is that we can't tell the amount of time that the gorotins will take to finish. So we will not be experimenting every single algorithm and maybe if we increase too much at the time we will be losing the ability to finish the program as soon as possible. So this is not good but we still don't need waiting groups to solve this specific problem. We can use conditional variables in order to wait in the main thread and then release it. Let's see. I'm gonna create the condition variable and I will use the new count function passing the mutex as a pointer. Okay. So I will remove the time sleep and then I will lock and unlock this main thread. Good. Now let's put some logic here. I will wait until the count variable beat 100,000. Let's put it in a variable here. So here I'm gonna wait and then yeah that's it. Yeah, racing. No, what's happening here? Okay, I have to send this signal to the conditional variable. I forgot about it. Let's say after unlocking the thread I will send the signal and then I'm gonna release this weight. Let's see now. Okay, now it works. Good. Let's increase this value here. I don't know. It seemed like something 10 million. I don't know. Yeah, it's gonna wait until all the gorotins finish and then we'll release the weight in the conditional variable. Now we have an additional problem here. You see this function right here? We are not considering this function as one of the gorotins. So we are waiting but we are not waiting for these gorotins. So let's say that we have here something like where we have how we're written. Then yeah, let's time-sleep it. I'll have to decrease this. It will take too much time. Okay. Yeah, I don't know. Let's run. Let's test. Okay, I was expecting an error. Okay, you know what? Let's sprint something. Okay. Okay, that's what I wanted. Kind of. This gorotin is not finishing because it doesn't have time to finish. Let's see. Yeah, exactly. So when we were waiting in the time-sleep it wasn't finishing and when we stopped waiting it finished. I will be working around it still using conditional variables. I will try to increase this and I will have to decrease in the gorotin. Okay. I have to use mutex. I'm changing the shared resource. So yeah, let's see. Nope. Let's try a different approach here. Invert the logic. Okay. Yeah, this signal is missing. We have to inform the weight that it can be released. And now we got it. Yeah, so it took a while but now we have the conditional variable waiting for the main thread. Even that we had to have an algorithm there. But as you can see, it's kind of verbose. Let's make it worst. Let's say that we have a different gorotin here with a different number of iterations and we will have to add these other iterations to the logic. Yeah, it's getting... And we have to add one more because, yeah, the main gorotin. So yeah, this is not good. It's gonna work. I'm quite sure that it's gonna work but this is not good because it's not only verbose but it's hard to maintain. Okay, it worked. Good. Now that you know the problem and how bad it is to use conditional variables to control this, we will finally use the weight group to control that flow. So, I will comment that just to make it clear for us the number of things that we are avoiding just using the correct approach. So, let's remove this and add the weight group from the sync package. We have two approaches here. The first is we can add to the weight group all the iterations at once like this. Let's reproduce that here also. And yeah, as we have this other function and it will have to wait until it's done. So, we will add these two functions here to the weight group. And then in the end of the code we will wait for the weight group to be done. But we have also to mark each function as done in the end. So, here, also here and also in the main function, not the main function, the main goroutine. So, all of them are marked now as done in the end of the functions. That's it. This is the first approach. We are adding all the iterations to the weight group and then we are marking them as done in the end of the functions. And waiting in the end of the main thread. Let's wait. Okay, so as you can see, it's the same behavior as we got before. Let's remove this commentary here so we can make it clearer. Yeah, cool. But we're still adding the iterations at once. So, let's do that in a different way. Easier approach here would be adding to the weight group the number one in the beginning of each goroutine like this. So before the goroutine should be started, we will add one at the weight group. And then we are going to defer the weight group done function in the beginning of the goroutine. So, this way it's more readable. So, we remove this and we put this in the beginning of the goroutine. Yeah, that's the same here and here. Yeah, so this approach is way easier to read. We are basically adding one before each goroutine and in the beginning of the goroutine, we are calling the done function from the weight group. So, this way we can assure that we are not forgetting to mark any goroutine as done. Cool. Let's run that. We are reading the end here. Looks good. Let's run. The results should be the same. We only changed the approach. Okay, same results. And let's create a new goroutine here with other iterations. And yeah, so same thing. Let's put here three. So, you're going to see that it's way easier to maintain these approach because you just have to remember to add and to mark each goroutine as done. The results will be pretty much the same except that we have a new message. Okay, looking good. So, we have everything in place and that's it for the weight groups class. Yeah, we are going to see more real examples on how to use mutexes, condition variables and weight groups in the coming class. So, we can have a better idea on how to use these structures, these tools in the real world. Thank you for that and see you in the next class. In this class, we are going to talk about atomic operations. In goal, atomic operations refer to operations that are performed atomically, which means they are indivisible and can't be interrupted by other concurrent operations. In many cases, they can replace the use of mutics and deliver a most efficient, performatic, and less verbose code. We will see that they are used for simple operations in variables, as for example, changing or retrieving values. These operations are designed to prevent data races and ensure synchronization when multiple gorotins assess the same variable simultaneously. So, now that we have a good knowledge about the use of mutics, I decided to brought this ready done example here to make it easier for us to understand where we are going and to make a comparison between the atomic operations and the mutics. So, let's go. So, we are iterating here 1000 times in this far and then we are creating gorotins inside of this. In each of these gorotins, we are incrementing the variable count and in order to do that securely, we are locking and deferring to unlock in the end of the function. So, we are unlocking and yeah, we have this differ function here that's also a mark in the weight group as done. In the end of the main thread, we have the weight group waiting for the weight group to be done and we are printing the variable count as the last action. Let's run this program and see the results. As you can see, we have always the 1000, it's perfectly working. But let's remove the mutics from this approach and we know what's gonna happen. Now that we don't have the mutics, the variable will not be changed in a secure way. Each gorotin is gonna change it concurrently. So, we have to use mutics in this case. So, let's uncomment what we've done here and let's start talking about how to do this using atomic operations instead of mutics. Let's remove the mutics from our code and I'll be running the program again, just to us to see the problem again. So, and then let's add atomic operations in this count variable. So, the first atomic operation that we are going to use here is to add an integer in the variable. As you can see in these functions, we have integer 32 and 64. So, we're gonna change the primitive type of the count variable to integer 32. Then we will use the function add int 32 and this function will receive two parameters. The first one is the address of the count variable. So, it has to be a pointer and the second parameter is the delta that will be added to the pointer. In this case, we want to add only one. Also, this function will return a value which we're not gonna use right now but we could use it if we wanted. Yeah, so, we have a new value being returned but we don't want to use that right now. We don't need to use that in our code here. We also have to use atomic operations when reading or loading values from this shared resource. So, we are gonna use the atomic dot atomic dot load integer 32. Same thing. We have to pass the address of the variable as a pointer and the function will be returning the value, the current value of the variable. That's it. We are assuring that the variable is changing inside of these 1000 gorotins without racing and it's how we use atomic operations instead of mutics in this kind of operation. We have an additional way to use atomic operations in Go that's not calling the atomic package directly. Instead of using the primitive type integer 32 in the variable, we can use a type from the atomic package. In the integer 32 struct, we'll have some functions, the same functions we were using and we can use this directly in the code. So, instead of calling this, we will call the account variable and use the function add. That's in the delta. That's it. And instead of loading this way, we will load the variable using itself. So, load. So, this way we are not using an integer 32 variable and using atomic operations, but instead we are using an atomic variable and calling atomic functions. The results will be pretty much the same. Now, how this is better or worse than mutics? So, basically, let's try a different stuff here. Let's say that we want to print this variable inside of these gorotins right here. This is going to introduce a new problem for us. We are loading the variable, that's an atomic variable, and let's see the results. So, as you can see, our program is again racing. But why? The fact is that the println function from the FMT package is not an atomic function. And this is a problem when it comes to atomic variables. This is not an atomic operation. So, if we want to do that in a correct way, we will have to use mutics again. So, we would have to lock the thread and to unlock the end of the function. Let's run again. So, this is going to work. What I'm trying to explain here to you is that if we want to use atomic operations, we have to get rid of complexity in our code. So, I gave you the example of the println function, but it could be any other operation that's more complex than retrieving or adding value to a variable. So, if we want to use atomic operations, we have to make our code as simple as possible in order to achieve this. We could also use atomic operations to change more complex values, not only integers or booleans. In this example, we are going to use a struct called person. And inside of this struct, we will have two fields, the name and the age of the person. When assigning this struct to a value, we will use an atomic value. So, from the atomic package, we have this value struct. Right here, we will initiate our person inside of the atomic value variable. So, we can use the function star, and it receives an interface or any value. So, we will put here our person. I'm going to call my name and my current age. So, let's say that we want to change the age of this person inside of the girl routines without racing, of course. First of all, let me clean up a little this code here. I'm going to remove this count variable that we are not going to use anymore. I also got rid of this defer function here because, yeah, we don't need this. Let me just defer the weight group done. So, it's going to be clearer for us to understand. Now, the way we change the person using atomic operations, we have first to load the person to a variable. So, using the person, atomic variable, we can use the load function, but this is at any function return. So, we will have to cast the result of this to a person. So, the way we do that, it's a simple cast and go. We are going to cast to person. Another point here, and I just noticed that, is that we are not using a pointer when storing the person up there. So, remember, when using atomic operations, we have to use pointers. This is the way it works. Now that I loaded the person in the P variable, I'm going to change its age. And in order to do that correctly, we have to use atomic operations. So, let's call atomic and let's add one to this age. Remember, we have to pass it as a pointer and then the delta that's going to be added. Okay, looking good. Let's print our person in the end of the program. Okay, let's write. Yeah, so this is the address. Okay, remember, we have to use atomic operations. We have to load the atomic value and we have to cast it. Yeah, now we see that the age was increased 10 times and it's 45 right now. Without racing. Let's increase it here. Yeah, so it's adding 1000 to the age. And, okay. Yeah, looks good. This is what I wanted to show you in terms of changing more complex types using atomic operations. So we were not only able to change the person value, but also we were able to change property inside of the struct. So this is it for the atomic operation class. I hope you enjoy it and see you in the next class. In this class, we are going to talk about pool. The pool type in Go provides a simple object pool that can be used to store and reuse objects, reducing the overhead of memory allocation and garbage collection. It's especially useful for managing frequently allocated and short lived objects. The pool type has two main methods, get and put. When you call get, it checks if there are any objects available in the pool. If there is an object available, it returns that object. If the pool is empty, it calls the new function, if provided, to create a new object and returns it. When you are done using an object obtained from the pool, you should call put to return the object back to the pool. This allows the pool to reuse the object for subsequent calls to get, reducing the need of creating new objects. Any items stored in the pool may be removed automatically at any time without notification. And a pool is safe for use by multiple goroutines simultaneously. A good example of the use of pools would be handling database connections. So let's say that we have a struct here called DBConnection and let's leave it without fields. Let's say that we want to use this database connection inside of our main thread. So when it comes to database connections, we usually open and close them as we use them. And let's say that we have more than one goroutines using that database connection. So we don't want them to concurrently close and open connections. So in this case, Apple is a very good approach. I'm gonna create a bunch of goroutines here and then we will initiate the DBConnection struct inside of each of them. By now let's just print the connection in our code. Okay, we have to wait in the main thread so we can wait until the goroutines to execute. Okay, we have the connections in the console but let's make a way to differentiate them. So I'm gonna add this index and I will increment this count variable every time that we start or initiate a new connection. Let's use an atomic operation here. Okay, I will change this variable to integer 32 and pull. Let's run again. Okay, now we have 10 different connections being created inside of the goroutines. So imagine that we have database operations running inside the beach of these goroutines. So we would have 10 different connections being created. So now we could improve that using pull. I will declare this new variable here called connection pull. And we will call a pointer in the sync package destruct called pull. Inside of this struct, we will implement the method called new. This new method will return the object that we desire. In our case, we will return the DB connection with an incremental index. So I will still use atomic operations and increment the count variable every time that we call the new. Now I will remove this and I will call the get. There are some important points here. The first is that the get function has no relation with the put function. So it doesn't mean, if you are getting, it doesn't mean that you are getting the object that it was put right before the get. We will understand that further. Another important point is that if we have the new function implemented as we have, get will return the value of the new if the pull is empty. Maybe it will be clear after we run our example. So I will only print the connection as it was an operation. And I will put back the object comb to our pull. And that's it. Let's run our program. Something wrong here. Let me check. Okay, we have to return a pointer in the new function. That's it. Okay, now we got it. And as we can notice here, we have already some repetition. So if you observe well, we have one being printed twice for being printed more than one time. So this means that some of the connections were reused. And we are potentially saving resources CPU memory, network throughput. I will add some time sleep here in the middle of the girl teams. And also the bottom of the for loop. So we can debug this behavior easier. Okay. So now that we are sleeping in the middle of the girl teams, we can easily notice that the connections are being reused by the code. So we have 123 and then one again, and three after that. And that's the behavior that we want. We are using the connections from the pool as they are available. Which will not happen if I remove the put, because right now we are not putting back the objects into the pool. So it's important to understand that we have to use the object. And after they use, in this case, we are only printing, we have to put the object back. And our example is running correctly again. There is one last thing that I have to mention. And I couldn't find a way to demonstrate that in this class. But I did mention in the beginning, every time that we are using pool, we are creating and using and putting back objects in that pool. But we don't have much control on how much time each object will be there. So any items stored in the pool may be removed automatically. And we don't have control of that. So this is something that's hard to show here, but it worth it to mention. That's it for the pool class and see you in the next class. In this class, we are going to talk about channels. Channels in Go are fundamental feature of the language that provide a way for goroutines to communicate and synchronize with each other. A channel is a conduit or pipe through which data can be sent and received. It acts as a synchronization point, ensuring that sending and receiving operations are coordinated between goroutines. So what we are going to see is basically this. Let's say that we have two goroutines, and we want to exchange messages between them. To do that, we create a channel. So the sender goroutine sends a value to the channel, and the receiver goroutine receives from it. We will learn further in this class that goroutines can be blockaded and unblockaded, depending of the type of the channel that can be buffered or unbuffered. We will also see that we can have multiple goroutines connected to the channel, receiving messages from it, and also sending messages to it. Let's dive into the code and understand better these concepts. As our very first example, we are going to code exactly what we've drew in our diagram. So we will have this sender function, and we will also have a receiver. Both of them are goroutines. So we want to send a message from this goroutine and receive it in this other goroutine. The way we do that is creating a channel. This is the way we create a channel. We use the function make, and we say the type of the channel. It's important to mention that this is an unbuffered channel, but we will see that further. Now we will send a message to the channel. So in the sender goroutine, we use that send operator. So this is the way we send messages into the channel. And in the receiver, we will use the receive operator, which is the same but located on the left of the channel. And we are going to print our message variable with the value from the channel. We should also block our main thread here so we can wait until both goroutines to execute. Let's run it. Okay, so we have the message. Let's change this message. Message one, two, three, something like okay. And then run again. So we are sending a message in the first goroutine and receiving it in the second one. It is important to say that the declaration order here doesn't matter. We can have the receiver being declared first and the sender after, and it will not be a problem in terms of results. Let's also put a time sleep in this sender so we can see things in a more debugable way. So the receiver will be waiting, but the sender will wait some a second until it sends the message. Let's run it. Okay, as you can see, it delayed one second and then it sent the message. So this is our very first example. Let's go for the next one. This example, it's similar to the previous one, except that we are going to zoom in into the possibilities when it comes to channels. So we will have two functions, the sender function and the receiver function, same as before, but we are creating separate functions in this example. Here we are going to add the channel as a parameter in the sender function. And I'm going to introduce to you a new concept. Here we will say that this channel is a send only channel. We do that using the send operator right after the chain reserved word. This means that if we try to read the message from this specific channel, we are going to receive an error. So it says that cannot receive from send only channel. This is a way for us to assure that this channel and this specific function will only be used to send messages. The opposite is also true. So we can have a channel parameter that is a receive only channel. We are using the receive operator right before the chain reserved word. And as you can see, we are not able to send messages because it is a receive only channel. Again, this is a very good way to make sure that we are using correctly the channels. Now in the main function, we will create our channel, our string channel, and I will call the sender function and the receiver function, of course, using the go routines. I will also block the main thread so we can see the results in the console. Let's also implement our functions. I will at first send a single message into the channel and receive the same message printing in the receiver. Let's run this and see the results. Okay, this is basically what we've seen in the previous example. So let's make it different. Instead of sending only one message to the channel, let's create a loop and send a few more messages. So here we will use the send operator and send a message to our channel. I will be using sprint fee functions here just to enumerate the messages so we can differentiate one message to the other. Okay, now that we have more than a single message being sent to the channel, we have to read from it. The way we do that is using a four statement and ranging the channel. This way we are going to read the messages from the channel in an uninterrupted way. So a quick review. We are sending messages in this routine, which is sending five messages and we are reading from the channel in the receiver function. Let's run our program. We have five messages in the console, which looks good, and let's put the time sleep in the receiver function so we can follow the messages being printed. Let's run again the program and as you can see we have a delay, but we have the messages being delivered in the console and everything looks good. But what happens if I add one more receiver in our code? So let's see this behavior. As you can notice, we have two messages being read at the same time and if I add an additional gorotin here, we will see three. This means that we can have multiple gorotins reading from the same channel. The number of messages received didn't change, but we could observe a way faster reading from the channel. Now the opposite is also true. We could have more than one gorotin sending messages, so in this case we would have 15 messages being sent and three receiver gorotins reading them. If we inspect this console, we are going to see three times the message two, three times the message four, and three times the message three, and three times the message one, so the number of messages delivered are correct. Let's go for our next example. In this example, we are going to see that we can use channels not only to exchange messages between gorotins, but also to synchronize them. So I'll create a channel of Boolean and I will block the main thread using the receive operator. So as it is a unbuffered channel, it's going to block the main thread. Then I will create a gorotin and inside of this gorotin, I'm going to time-sleep Liro, so we can debug that. Okay, two seconds, and then I will send a message using the send operator to the channel. So this will unblock the don, and I will print a message that the main thread was unblocked. Let's run our example and see the results. So as you could observe, after two seconds, we sent a message to the channel and then the main thread was unblocked. Then we saw the message in the console. There are a few more ways to synchronize gorotins using context, for example, but this is subject for another class. In this example, we are going to see the differences between unbuffered and buffered channels. I'll create this string channel and I will send a message into this channel right after the creation of it. As simple as that, let's run our program and see the results. We got an error in the console and it says that it is a deadlock in the line 7. So the problem here is that when we send a message to a channel, we are blocking the thread. And in this case, we are blocking the main thread. We will see the same behavior if we try to read from this channel. So I'll try to print the value from this channel and we will see an error again. Because this time I am trying to read from a channel in the main thread and as it is a unbuffered channel, it is blocking the gorotin. If we want to send and receive messages to our channels without blocking it, we should use buffered channels. To do that, we have to say the size of the channel in the make function. Let's run our program this way and see the result. So as you can notice, we don't have anymore the deadlock message in the console. Let's try to print our value from the channel using the receive operator. Okay, let's run. So as you can see, we were able to send a message to the channel and read from the channel right after it without any errors. Now, what is going to happen if I have another message being sent to the channel? The problem here is that the channel at this point is full because we have a channel of the size of one. Let's run it this way and see what we got. We have again the deadlock message in the line 10, sorry, in the line 12, because we are trying to send a message to a ready full channel. The same thing will happen if we try to read from a channel that's already empty. Let's see. Okay, we have now in the line 14 the same error, deadlock error, because we are trying to read from a already empty channel. The way we solve that is increasing the size of our channel and then we will be able to send more messages and to read more messages from the same channel. Let's run and see the results. Okay, everything looks good. So by concept, we can say the following about buffered channels. Buffered channels have a specific capacity greater than zero. They can hold a certain number of values without blocking the sender. It will only block the sender if the buffer is full. Similarly, it will only block the receiver if the buffer is empty. That's it for the channel's class. I hope you enjoyed it and see you in the next class. In this class, we are going to talk about the select statement. The select statement in Go is used to handle multiple channel operations concurrently. It allows you to wait for one or more channels to become ready for communication and then perform the corresponding operations. By utilizing the select statement effectively, you can write robust and efficient concurrent code in Go, allowing good routines to communicate and synchronize seamlessly. Let's say that we have some sender go routines. Remember, it can be multiple go routines. And we have some channels. Let's make it three. Listening to these channels, we have a select statement. And inside of the select statement, we have some case blocks. One case block for each channel. The sender go routines are independently sending values to the channels. The select listens to all channels and forwards the messages to the case block that matches each channel. The select statement only allows one case block to execute, even if multiple channels are ready simultaneously. Only after executing the selected case block, the execution continues, and the next ready channel will be read. Our code example will be simple. We will create two channels or more, and then we will read from them using at first go routines and then the select statement. So let's create our channels here. Both of them are going to be string channels. So we have ch1, ch2, and we will send inside of go routines, we will send messages to these channels using the send operator. We send a message to the first channel. Let's copy and paste that. And then we have the channel to receiving a new message. We also have to read from these channels. So let's create additional go routines for each read operation. So I will iterate the channel one in this go routine. And I will print the value inside of the loop. Okay, let's do the same for the second channel. Just changing to ch2. And let's block our main go routine. That's it. We have these first two go routine sending messages to the channels, channel one and channel two respectively. And we have the last two go routines receiving values from them. Let's run our program and see the results. Okay, as you can see, the results are okay. We have the messages being printed in the console. But we had to have one go routine for each channel. Let's say that we have a third channel in this program. And let's create a new go routine to send messages to this additional channel, channel three. And then we will have to read from this channel in an additional go routine. Yeah, so this is pretty much working. And I will introduce to you the select statement. So instead of these three go routines reading from the channels, we will have only one go routine. Inside of this go routine, we will have a infinity four. And we will have the select statement. Inside of the select statement, we will have the case block. And each case block will listen or receive messages from each channel. I will just print the values inside of each case block. So here we are going to say that this message came from the channel one. And we will duplicate this code to the other channels. So we have we have three case blocks, listening to different channels and printing the messages. That's right. Okay, let's put a slash end here so we can create a new line after each print. Cool. So we have the messages being printed in the console. Let's say that we want to create one more channel. We have CH4, we will send the message to this new channel. And we will add this channel at a case block in the select statement for this specific channel, CH4. I will also add an additional case block. And I will use this time after function. Okay, and this function will wait until a time duration. And then it will send a message to the channel. Then we will print that five seconds has passed. And we can also panic our application. So we can interrupt the program. After running it, we will see that after five seconds the program will be interrupted. Okay, and we have the message five seconds without messages and a panic, no messages. We can also add default behavior for our select statement. So in this case, we will time-sleep for some time and also print a message that we don't have messages. So if any other channel receives no messages, this behavior will be reproduced. And after five seconds, we should see the interruption, which we are not seeing. Okay, so as the default behavior is here, we are, every time that we are passing in the time after function, because the time after function is based on the time now. So it's restarting the restarting the clock every time. So we can create a variable and assign this time after value to this variable instead of calling it every time in the case block. So I will create this time out variable and I'll put this time out. This is a channel in the case block. We receive operator. Okay. Yeah, let's run again. So now we should see the interruption after five seconds. Okay, so it's working fine. It's important to mention that the select statement only allows one case block to execute at a time, even if multiple channels are ready simultaneously. That's it for the select statement class and see you in the next class. In this class, we are going to review some of the concepts that we've learned so far. The idea is to develop a web crawler that will be able to make ATTP requests and handle its responses. In this example, we'll use girl teams, mutexes, weight groups, channels, select statements, and atomic operations. It is important to say that this is an extra class. So if you feel already comfortable with the subjects I mentioned, you can move on to the next one. But if you think that it's worth it to see again these topics, then let's go to begin with our application. I will create a list of URLs that we are going to request. So I will put here google.com, also open AI, and I will put a third option, unreachable or a not 200 response URL. Let's keep it Google with not found a path. Now that we have the list of URLs, we are going to iterate them using the for loop statement. So inside of this loop, we are going to use the HTTP package. And we're going to call the get method passing the URL. We get back the response and the error. And we are going to check if the error happened or not. Let's just print it at first, okay, slash and okay. And we will continue in the loop. So we interrupt this iteration. Also, when it comes to HTTP requests, we have to close the body in the end of the function. So we are using defer. And we will print a successful message before returning. Yeah, I will print only the status code. And it looks like we are missing the range keyword here. Okay, let's run. Okay, we got some responses here. But let's put the URL in our print function so we can have a more trackable output in the console. Okay, running again. That's better. But this far over here, it isn't exactly a successful response. Let's let's make it better. So I'm going to check if the response status code is different from the status, okay, from the HTTP package. If it is different, I'm going to print it as an error. Better saying. Maybe it is not an error, but a no successful response. Let's change it here. So this is a no success. Okay, let's run. I forgot something. Okay, I don't have error here. So I will just print the response code. Cool, let's run again. Yeah, now we see the far offer as a no successful response. That's better. And let's add one more URL here in order to make an error happen. Okay, running again the code. Now we see a message error message saying that no such host in the non-existenturl.com. Now that we have a basic example of the application running, we are going to improve it using the tools that we've learned. So let's go. Our first improvement here is to separate this section of code in a different function. Let's call this new function, fetch URL. It's going to get the URL as a string. It will be the same we were doing in the iteration. Except that we have to return to interrupt the function. That's it. Now we have to call this function from the main go routine. And we are going to use go routines here. So we can make a lot of requests asynchronously. Let's run this and see the behavior. This is an expected behavior. We've seen that we have to give enough time to the go routines to finish. The way we do that is using weight groups. Let's add to our weight groups the number of URLs that we have. And let's mark the weight groups as done in the end of the fetch URL function. What am I missing? Okay, I have to add the weight in the end of the main go routine. Running again. Now we got it. We have the results. Everything is working and we are using go routines, weight groups and that's it. Let's evolve a little more our example. Instead of printing as we are doing here in this function. Let's send these messages to channels. So I will create two channels. A success channel that will be a string channel. And also a failures channel. Also a string. I will initiate these two channels in the beginning of the main function. Because we have to use the make function when it comes to channels. And instead of printing the messages in the fetch URL function, I will send messages to these channels. So here I will send the message to the failures channel using the send operator. And here is also a failure. But if the response code is 200, I will send it to the success channel. I think that's it. Let's run our code. So what is happening here? The problem is that we have the failures and success channels. And we are only sending messages to them. And both of them are unbuffered channels. So what happens when we send messages to unbuffered channels and we don't have go routines listening or receiving these messages? They are blocked. I will create them a select statement in order to receive these messages from the channels. We have this structure just go routine with a far inside of it. And then we have the select statement with the cases. We are going to have two case blocks here. We are going to have the failure case block. And at first I will just print the messages. And we also have the success case block. So it's going to receive the successes channel using this receive operator. And I will just print it. Okay. I think that's it. Let's run our code and see the results. As you can see, we should have four URLs being printed, but we have only three. And this is happening because this weight group done will have to be moved to the select statement because here is the end of the whole operation right now. So we want the weight group to be done only after it's being printed. Let's take another look at the results. Now we got it. It's working again. We have the same results as before, but at this point we are using channels to send messages. And we are using the select statement to receive these messages and printing them. Okay. Let's enhance a little more our example. At this point, we are sending messages to string channels, but we can have more complex types as channels. So let's create a struct here called response. And let's add some fields. We are going to have URL, code, and also a message. Now, instead of creating a channel of string, we are going to change these channels for the response type. The make also. And we will change the fetch URL function. And we are going to send a response to the channels. So I will send the URL, the code here. We don't have the code because we don't have a response here. So we don't have the code. Let's keep it blank. And I will say I will send the error message here. Here in the failure when the status code is different from 200, I will send the code that is coming. Status code. And I will send the status message here. Okay, I will do the same in the success, except that we are sending to the successes and not to the failures channel. Okay, we don't have to change anything in the select statement. Looking good. Let's run and see the results. Okay, we were using the print function in the select. Let's change it to the print a land. So we can, we can see a better result. Okay. Yeah, we have the same results as before, except that we are seeing it more structured this time, because we are sending the URL, the code and the status or the error message. And we are printing it. Okay, let's move on. Now that we have structured messages being sent to the channels, we can use it and implement some logic. I will create a new type called summarized response here. In this new struct, we will add two fields. We will have the code and the number of occurrences. So the idea here is to have a summary report of the number of occurrences that each code had. We will have a variable that will be a slice of summarized response. Okay, now we can create a function that will implement our logic and make this summarized responses report. So this function will receive a parameter response, which is the structure that our channels are receiving. And we are going to do a logic here. Basically, at first, I will just append to the slice of responses, the summarized responses. I will append the response. And I will inform the code of the response and the number of occurrences. It will start with one. And then we will have to iterate these responses, the summarized responses, we are going to iterate it to increment the occurrences when it happened again. So basically, I will iterate the some reps variable. And I will check if the response code that's coming, it's already in the slice. So if it's already in the slice, I will only increment the occurrences and I will return. And if it isn't, it will end the iteration and add one to the slice. A very important point here is that we are using a shared resource. So we need a mutex to make sure that we are changing the shared resource properly. So we lock and we unlock in the end of the function. Or before returning, better saying. Okay, let's now call this function in our select statement in our case blocks, passing along the response. It will be the same. And that's it. Before the end of the program, we will we will print our summarized responses. So we let's iterate this and print each result. So let's say here this is a results by code. Okay, let's run our program and see the results. Okay, as you can see, we have 2, 200, 1, 0, that's an error. And we have a 404. So we have this summary of what happened in the whole application. I will add a few more URLs here so we can make sure that our report is working. As you can see, it's fully working. And that's it. Let's go ahead and keep improving our application. The last piece of improvement in our application will be atomic operations. We are going to create a new struct. We will call it app statistics. And at first we have only two fields, the success responses, that will be atomic integer 64, and also the failures responses, that will be also atomic 64. Now that we have the struct in place, we will create a variable and initiate the app statistics struct. Now in the select case blocks, we increment or add one to these atomic operations. The failure case block also, okay, add one. And we will also print some results in the end of the code. Let's say here statistics. And we are going to use the load function from the atomic variables to load the values from them. So here we will have the successes. And we will also have the failures. Okay, let's run it. So we have in the end of the console, we have the after the results, we have the statistics. And it's saying that we have two successes and two failures. We are going to add a few more statistics to our application. One of them will be the idle time of the application. During this time, we have nothing happening. So in this default function, we will implement this logic, the idle time logic. So we will have a start time here in the beginning of the for that's going to be time dot now. And we are going to use the function sense from the time package to measure how much time has passed during this interval. We want this time to be in microseconds so we can be as precise as possible. And we will add this to a new variable idle time. And then we will increment this time in our app statistics struct. Let's add the field first. So idle time, we will also add this as atomic integer 64. And then we can add the idle time variable to this atomic operation. Okay, let's also print it in the end of the program. This is going to be the idle time. And let's run our program. Cool, we have, yeah, this is in microseconds. Let's make it a second variable. Second. And let's see again. Okay, we will need this to be a float 64. So we can see decimals in the console. Okay, now we have in seconds the amount of time that was that the application was idle, which is very little time. But yeah, this is a good statistics to have. Let's keep adding some more statistics. I would like to have here in the fetch URL function, the amount of time of each request. So I will implement it the same way we did in the select, but this time we are deferring the calculation of the sense function to the end of the function. Cool, microseconds also. And let's add a new field in our statistics struct. It's going to be called request time. Also, atomic integer 64. And we got a problem because this app stats variable is not reachable in the fetch URL function. So make it a global variable. And then it's going to be reachable from here. Let's so use the add operation to add the request time to the variable. Let's also print this one. Grasp time. Perfect. Request time. Yeah, I will also add one more statistics variable to our struct. And this one is going to collect the total time of the application. Same logic, we are going to add a start time variable in the beginning of the main function. And we will calculate it after the wait. So using the time sense function, and then adding the result to the app statistics, app statistics variable using add atomic operation. Okay, it's going to be microseconds also. And we will also print it in the end of the app. Cool, let's run. Yeah, yeah, I forgot to change this variable to app total time. Okay, let's run. So now we have the idle time, the request time, which is less than one second. And we have the total time that's less either. It's very interesting to mention here that the app total time is less than the request time. And this is happening because we are using code routines. And we are taking advantage of the concurrency tools of the language. So if we were going to do request by request, it would take more than the whole application is taking right now. For the final test of our application, I made a list of 100 requests. We are going to call them and see how the application will behave. So let's replace this for the get URL function. And let's run the program. So as you can see, we have some results here, like we have 11203, we have 85200, three times far 2980tp code. Let's double check it. So we have yet three requests with the two menu requests code. And we have one error. That's the Twitter right here. Okay, then we have the statistics. And we have 85 success, 15 failures. The idle time was almost nothing. And remember, this is the time that the select was not receiving messages from the channels. The request time was 61 seconds. And the app total time was one, almost two. So this means that it would take more than one minute to this application to run if you were not using any concurrency tools. And again, so yeah, this time we have different variables, but yeah, similar results. Yeah. So that's it for the review class. I hope you enjoyed it. And see you in the next class. In this class, we are going to talk about the data race detector. But first, we have to understand what a data race is. A data race occurs when multiple parts of a goal program access the same data simultaneously, with at least one of them performing a right operation. This lack of proper coordination can lead to unpredictable behavior and bugs that are hard to identify. To help developers detect and fix data races, Go provides a built in tool called the data race detector. The data race detector is enabled using the dash race when running a goal program. When enabled, it monitors the memory accesses of running goal routines and detects when there is a potential data race. If a data race is detected, it prints a warning message to the standard output, indicating the location in the code where the data race occurred. To exemplify the use of the data race detector, we are going to create a very simple example. We will have this data variable. And we are going to change this variable inside of a goal routine. We are going to assign the 42 value to the variable. And then we are going to read from the same variable. In this case, I will be printing in the console. Okay. Let's also block our main goal routine. So we can give enough time to the goal routines to finish. Now we are not using mutex or atomic operations here. But yeah, let's run and see the results. So it looks like it's working fine. But when we run it using the dash race in the command line, we'll see the problem. Yeah, so we have this error in the console. Let's take a look at it. So we have this read. We have this warning data race. And we have this read in the line 14, which is the print a land function. And we also have this right happening in the line 10. So they are trying to change the shared research concurrently. We also have the goal routine number and the line that they are declared. So we have here line 13, and line 90. We can find not only the line that's causing the problem, but also the goal routine. We have several ways to solve this problem. We can use atomic operations, as we have already seen. So we add value to the variable and reload. And running again, we will not see the error in the console. And another way to solve this would be using mutexes. So instead of using atomic operations, we can declare a mutex variable. And we can lock and unlock every time that we are changing or reading the variable. That should work the same way. Yeah, and it works fine. To demonstrate a little more about the data race detector, I brought back this example from the conditional variables class. Let's read this code to recap what this is doing. So basically, we have these two functions, producer and consumer, and they are incrementing and decrementing the variable counter. We are using mutex and also conditional variables. So after the producer increment the variable, it sends a message to the consumer, which decrements and sends a message to the producer, and so on. Let's run this. So here we have incrementing and then decrementing. And this is going to be for five seconds working this way. Let's then remove the mutex and the conditional variables from our code and see what happened. Okay, okay, let's run the code. As you can notice, we don't have the correct behavior anymore because we are not using any kind of synchronization, mutex, for example, and we also don't see any error in the console. Now, using the race detector, we are going to see a lot of errors. Let's wait until the end of the execution, and then let's read this console. Okay, we have six data races found. The first one is this one, we have data race in the line 44, which is the decrementing action. And then we see that we have in the line 29 the incrementing action. So one is racing against each other. And we have the goloutines lines that will be always 15 and 16 here in the beginning of the main function. In the second warning, we have a different line, line 40, which is the if of the consumer, and it is racing against the line 30, which is the print function. After this, we will have the line 44 again, decrementing, and racing against the 29. Okay, that's it. I wanted to show you that the data race detector can provide a very detailed report on which part of the code is racing against other. We also have learned that the data race detector will not stop the execution of the program, but it will be logging the warnings during the execution of the program. So that's it for the data race detector class, hope you enjoyed it and see you in the next class. In this class, we are going to talk about contexts. The context package is used to manage the lifecycle of operations within a concurrent program. It is especially helpful in scenarios where multiple goloutines are working together to accomplish a task, and you need to ensure proper coordination to avoid resource leaks or unnecessary waiting. The context package provides a context type, which holds information about the context of an operation and facilitates communication and synchronization between different parts of a program. Each context instance can have a parent context, forming a hierarchical tree like structure. When a context is canceled or times out, all its child contexts are also canceled automatically. Imagine that we have a main goloutine with a function. We are going to create few goloutines that we'll call this function. When creating these goloutines, we'll pass along a context. We'll see that this context can be of the following types, with cancel, with cancel calls, with timeout, with deadline, and with value. In the next code examples, I will show you how to use each of these contexts. Let's go. In this first example, we are going to create a function called worker, and we will receive an integer parameter called id. Inside of this function, we will have this far, and we will print a message. Okay, let's use the print function here. Cool. And let's also slip a little in this iteration. Okay, in the main goloutine, I will call this worker function twice, passing different id parameters. And I will also wait in the main goloutine so we can watch what is happening. Yeah, let's run. Okay, I forgot about the slash n here. Now we can see in the console the worker one and two working and printing message each second. Let's introduce the use of contexts in this example. I will first create a root context, and we will use as the base context the background context. I will create an additional parameter in the worker function. And this is going to be a context interface, which means that we can send here we can pass here any kind of context. Now I will pass the root context in the goloutines. But this is not changing anything in terms of behavior. So I will create a new context using the context package called with cancel. In this with cancel function, we have to pass a parent context. As you can see, we are going to use the root context. Now this function will return two values, the context itself and a cancel function. So we will attribute these values to two new variables. We are going to pass this new context to the worker goloutines. And we are going to use this cancel function to cancel the context where it's being used. So in order to exemplify this, I will create in the worker function, I will create a select statement. In this select, we will have two case blocks. The first one will be the context don channel, which is provided by the context interface. So here we will print a message saying that the goloutine is finished. Let's change this a little. So worker ID context canceled with error. And then it's exiting. Okay, we have to pass the error as a parameter. And we are going to get the error also from the context. Okay, we have to return after the case block. And we will have a default case block, which will be the time sleep with the message that the worker is working. Okay, now this context.don channel will be triggered only when the cancel function will be called. So we will create a new anonymous goloutine. And we are going to pass the cancel function as a parameter here. So we will use the context package and cancel func. Okay, here we will wait literal, let's say four seconds. And then we will cancel call actually the cancel function, which will send a message to the context on channel. Running this program, we notice that after four seconds, we have an interruption. A brief review of what is happening here. We have called the cancel function, which will cancel the context and trigger the don channel. We also printing the error message from the context, which is the context cancel message in the console. And that's it. Let's go ahead and see our next example. In this example, we are going to use the with cancel calls function. So let's change here to with cancel calls. And let's read this docs. So here we have now a cancel calls function. And we have to pass an error to it. And we can capture this error using the context package and the cause function passing the context. Here it's complaining because now we don't have a cancel function, but a cancel cause function. And we have to pass an error when calling it. So let's change here and let's create a error, the errors package, I'm going to call it ever x. And then we have to change the capture of the error here. So we will use the context package and we use the cause function passing the context. So this should return the error that canceled the context. Yeah, that's it. Let's run this example. Okay, let's read a little. As we can see, as we can see, we have the error x as the reason why the context was canceled. This can be very helpful when it comes to why the application stopped running. Let's go for our next example. Right now, we are using these anonymous go routine to sleep for four seconds. And then we are canceling the context. There's a better way to do that. And it's using with time out context. So instead of with cancel calls, we are going to replace this for with time out. And we have to pass a second parameter, which will be the time duration that we want to wait. That's going to be five seconds. And then it's going to return the context. And also a cancel function. So interesting thing here is that it's a good practice to always defer to the cancel function after the creation of the context. So in our example, we want the time out to happen. And then we will see the context deadline is seated in a message. Let's wait. And there it is. This means that the context reached the time out. And this is the cause context that line is seated. One interesting thing to notice here is that we could cancel this context instead of waiting until the time out. So let's call the cancel function here and better, let's wait one second. And then cancel the context. We have a different message in the console context canceled. And yeah, this is just to demonstrate that the context will be canceled one way or another are canceling the function or waiting for the time out. I will remove this cancel function from our code just to make it as simple as possible. Okay, we have again the context that line is seated. And that's it for the time out context. Let's go for the next example. In this example, we are going to use the deadline context. So I will rename this variable and then create with that line context. Okay, the first parameter is the parent. So I will pass the time out context here. And then the second parameter is the time that we want to wait until the context should be time it out. In this case, I will add three seconds to the now time. And I will increase this one to 10. So we will see the difference in the console. Also, this will return a context variable. And also cancel function, which I will not use right now. I will pass this context to the second worker. And that's it. So in this case, we are passing the deadline context to the second worker. And passing the time out context to the first one. So this one will wait 10 seconds. And the other one will wait three seconds. Okay, let's run and see the results. As you can see, the for the second worker was already time it out. And then the first worker was time it out after 10 seconds. So we have the root context being the parent of the time out context and the time out context and the time out context being the parent of the deadline context. This is exactly the three like structure that we mentioned in the beginning of this class. We are going to see some counseling order in the next example. So let's go for it. In this last example, we will demonstrate how to use the with value context. So we use the you value function, we pass the parent context. This case, I will use the time out context. And we have to pass a key. And as you can see, a key and a value, which are interfaces. So we can pass whatever we want here, I will use strings. This is going to return only the context variable. So I'm going to assign this value to this new variable. And I will create a new anonymous function to get this value of this key from this context. So we are going to pass the context as a parameter to this anonymous function. And we will wait until the context to be done. And then print a message in the console. Let's say here, anonymous function canceled with value. And then we are going to print the value that we are going to capture from the context. Yeah, context dot value, exactly. And then we pass the key that we want to retrieve the value. So we have the key here. Okay, that's it. Let's let's run and see the results. Okay, the first worker was canceled. Our time it out. Then we have the message in our console anonymous function canceled with value, the value. Let's change these key in these function here and see what's going to happen. Again, first worker, time it out. And then the other one. So we have, we don't have an error, but we have this new message from the context because the key is not there. Yeah, only wanted to demonstrate how it behaves. Now, it's very important to notice that the with value context was canceled our time it out. At the same time that the time out context was and this is happening because the time out context is the parent of the value context. So this is a good demonstration on how the three like and hierarchical order of the contexts will work. And this is all for the context class. I hope you enjoyed it and see you in the next class. Congratulations, you completed the course. You've gained valuable insights about the concurrent programming with call. And I hope you feel confident in applying this skills to your projects. Remember that learning is a continuous process. So keep exploring and experimenting with the concepts you've learned. If you found the course valuable, I would greatly appreciate your feedback and a review. Stay curious and keep coding.