Concurrency and parallelism are two essential concepts in software development that allow you to execute multiple tasks simultaneously. Although these terms are often used interchangeably, they are distinct concepts.
In this blog post, we will explore concurrency and parallelism in Kotlin and how to implement them using threads and coroutines with some code samples.
Concurrency vs. Parallelism
Concurrency refers to the ability of a program to execute multiple tasks simultaneously, regardless of whether they are running on different processors or not. It involves breaking up a task into smaller pieces and executing them independently of each other. However, concurrency does not guarantee that the tasks will be executed in parallel.
Parallelism, on the other hand, refers to the ability of a program to execute multiple tasks simultaneously using multiple processors. It involves breaking up a task into smaller pieces and distributing them across multiple processors for simultaneous execution.
Threads
Threads are the most basic mechanism for achieving concurrency in Kotlin. A thread is a lightweight unit of execution that can run concurrently with other threads within a program. Each thread can execute a separate task, allowing multiple tasks to be executed simultaneously.
Threads achieve concurrency by allowing multiple threads to run concurrently on a single CPU. The CPU switches between threads, allowing each thread to execute a portion of its code. This switching happens so fast that it appears as if all threads are running simultaneously.
Threads also enable parallelism by allowing multiple threads to run on separate CPUs. In this case, each thread is assigned to a different CPU core, allowing multiple threads to be executed simultaneously.
Threads are created using the Thread class, which takes a function or lambda expression as an argument. The function or lambda expression contains the code that the thread will execute. The following code snippet demonstrates how to create a new thread:
val thread = Thread {
// code to be executed in the thread
}
Once the thread is created, it can be started using the start() method. The start() method launches the thread and begins executing the code in the thread.
thread.start()
Threads can communicate with each other and share data using synchronization mechanisms like locks, semaphores, and monitors. However, this can be a challenging task, and incorrect synchronization can lead to race conditions and other concurrency bugs.
Coroutines
Coroutines are a more advanced mechanism for achieving concurrency and parallelism in Kotlin. Coroutines are lightweight, and they provide a more flexible and scalable approach to concurrency than threads. Coroutines enable asynchronous, non-blocking code execution, making them ideal for use cases like network programming or graphical user interfaces.
Coroutines achieve concurrency by allowing multiple coroutines to be executed on a single thread. This is possible because coroutines are cooperative, meaning that they suspend their execution voluntarily, allowing other coroutines to run. This cooperative nature enables a single thread to execute multiple coroutines simultaneously, resulting in highly efficient and performant code.
Coroutines also enable parallelism by allowing multiple coroutines to be executed on separate threads or even separate CPUs. This is achieved by using coroutines with different coroutine contexts, which specify the thread or threads on which the coroutine should execute.
Coroutines are created using the launch or async functions provided by the GlobalScope object in kotlinx.coroutines library. The launch function creates a new coroutine that runs in the background, while the async function creates a new coroutine that returns a result.
val job = GlobalScope.launch {
// code to be executed in the coroutine
}
val deferred = GlobalScope.async {
// code to be executed in the coroutine and return a result
}
Communicating between Coroutines using Channel
Coroutines can communicate with each other and share data using channels and suspending functions. Channels provide a way for coroutines to send and receive data asynchronously, while suspending functions enable coroutines to suspend their execution until a specific condition is met.
val channel = Channel<Int>()
val job = GlobalScope.launch {
for (i in 1..5) {
channel.send(i)
}
}
val deferred = GlobalScope.async {
var sum = 0
for (i in 1..5) {
sum += channel.receive()
}
sum
}
Coroutine Context
Coroutine context is a key concept in coroutines, and it provides a mechanism for managing the execution of coroutines. The coroutine context is a set of rules and properties that define how a coroutine should be executed. It includes information like the dispatcher, which specifies the thread or threads on which the coroutine should execute, and the job, which represents the lifecycle of the coroutine.
The dispatcher is responsible for assigning coroutines to threads. Different dispatchers are available, each with a different execution strategy. For example, the Dispatchers.Default dispatcher assigns coroutines to a thread pool, while the Dispatchers.IO dispatcher assigns coroutines to a pool of threads optimized for I/O operations.
The CoroutineContext interface represents a context for a coroutine, which includes information like the coroutine dispatcher and job. The coroutine context provides a way to control the execution of coroutines, including where they run, how they are executed, and how they are cancelled.
Let's explore how to use the coroutine context in Kotlin with a sample code.
import kotlinx.coroutines.*
fun main() = runBlocking<Unit> {
launch(Dispatchers.Default) {
println("Running in the Default dispatcher")
println("Current thread: ${Thread.currentThread().name}")
}
launch(Dispatchers.IO) {
println("Running in the IO dispatcher")
println("Current thread: ${Thread.currentThread().name}")
}
launch(newSingleThreadContext("MyThread")) {
println("Running in a single-threaded context")
println("Current thread: ${Thread.currentThread().name}")
}
}
In the above code, we are launching three coroutines with different dispatchers: Dispatchers.Default, Dispatchers.IO, and a new single-threaded context created with newSingleThreadContext("MyThread").
The runBlocking coroutine builder is used to create a scope where coroutines can be launched and executed synchronously. It is similar to the Thread.join() method in Java, which blocks the current thread until the specified thread completes.
When a coroutine is launched with a dispatcher, it is assigned to a thread pool managed by that dispatcher. In the above code, the first coroutine is launched with the Dispatchers.Default dispatcher, which assigns it to a thread pool optimized for CPU-bound tasks. The second coroutine is launched with the Dispatchers.IO dispatcher, which assigns it to a thread pool optimized for I/O-bound tasks. Finally, the third coroutine is launched with a new single-threaded context, which creates a new thread on which the coroutine runs.
When the coroutines run, they print out a message indicating which dispatcher or context they are running in, as well as the name of the current thread. The output might look something like this:
Running in the IO dispatcher
Current thread: DefaultDispatcher-worker-1
Running in a single-threaded context
Current thread: MyThread
Running in the Default dispatcher
Current thread: DefaultDispatcher-worker-2
In this example, we can see that the coroutines are running on different threads depending on the dispatcher or context they are launched with.
Example: Downloading Images
Using Thread
import java.net.URL
fun main() {
val urls = listOf(
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
"https://example.com/image3.jpg",
)
val threads = urls.map {
Thread {
val url = URL(it)
val stream = url.openStream()
// Code to process the downloaded image
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
}
In the code above, we define a list of URLs and use the map() function to create a list of threads that download each image in parallel. We then start each thread and wait for them to finish using the join() function.
Using Coroutine
import kotlinx.coroutines.*
import java.net.URL
fun main() = runBlocking {
val urls = listOf(
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
"https://example.com/image3.jpg",
)
val deferred = urls.map {
async {
val url = URL(it)
val stream = url.openStream()
// Code to process the downloaded image
}
}
deferred.awaitAll()
}
In the code above, we define a list of URLs and use the map() function to create a list of coroutines that download each image in parallel. We then wait for all the coroutines to finish using the awaitAll() function.
Comparison: Thread vs Coroutine
When comparing coroutines and threads in Kotlin, there are several factors to consider that can affect performance. Here are some of the key differences between coroutines and threads in terms of performance:
Memory usage: Coroutines typically use less memory than threads because they are not tied to a specific thread and can reuse threads from a thread pool. This means that coroutines can potentially support a larger number of concurrent tasks without running out of memory.
Context switching: Context switching is the process of switching between different threads or coroutines. Context switching can be a performance bottleneck, as it involves saving and restoring the state of the thread or coroutine. Coroutines typically have a lower context switching overhead than threads because they use cooperative multitasking, where the coroutine decides when to suspend and resume execution, rather than relying on the operating system to schedule threads.
Scheduling: Coroutines are scheduled by a coroutine dispatcher, which determines which coroutine runs on which thread. This allows for more fine-grained control over how coroutines are executed and can improve performance by minimizing the number of context switches. Threads, on the other hand, are scheduled by the operating system, which can result in less control over scheduling and potentially more context switching.
Scalability: Coroutines can be more scalable than threads because they can be launched and cancelled more quickly, allowing for more dynamic allocation of resources. Coroutines can also be used with non-blocking I/O libraries, which can improve scalability by reducing the number of threads needed to handle I/O operations.
In general, coroutines can provide better performance for concurrent and asynchronous tasks due to their lower memory usage, lower context switching overhead, and more fine-grained control over scheduling.
Conclusion
In summary, concurrency and parallelism are essential concepts in software development, and Kotlin provides two mechanisms for achieving these goals: threads and coroutines. Threads achieve concurrency and parallelism by allowing multiple threads to run on a single or multiple CPUs. Coroutines achieve concurrency and parallelism by allowing multiple coroutines to be executed on a single or multiple threads, with each coroutine being cooperative and suspending its execution voluntarily.
With a solid understanding of threads and coroutines, developers can write highly efficient and performant applications that can execute multiple tasks simultaneously.
Yorumlar