fundamentals/os/notes/threads-consolidated.md
2023-01-22 11:03:24 +00:00

11 KiB
Raw Permalink Blame History

Threads and Multithreading

Threads

A thread is a lightweight process. It is a unit of execution within a process. A process can have multiple threads. Each thread has its own program counter, stack, and registers. Threads share the same address space. This means that all threads in a process can access the same memory. This is different from processes where each process has its own address space.

Often, a process needs to perform multiple tasks at the same time. For example, a web browser needs to download a file and display a web page at the same time. Creating a new process for each task is expensive. This is because creating a new process requires a lot of resources.

Threads are used to solve this problem. Threads are used to perform multiple tasks within a process. This is done by sharing the same address space. This means that all threads in a process can access the same memory. This is different from processes where each process has its own address space.

Thread is a sequential flow of tasks within a process. Threads in OS can be of the same or different types. Threads are used to increase the performance of the applications. Each thread has its own program counter, stack, and set of registers. But the threads of a single process might share the same code and data/file. Threads are also termed as lightweight processes as they share common resources.

Threads

Thread vs Process

Process Thread
Processes use more resources and hence they are termed as heavyweight processes. Threads share resources and hence they are termed as lightweight processes.
Creation and termination times of processes are slower. Creation and termination times of threads are faster compared to processes.
Processes have their own code and data/file. Threads share code and data/file within a process.
Communication between processes is slower. Communication between threads is faster.
Context Switching in processes is slower. Context switching in threads is faster.
Processes are independent of each other. Threads, on the other hand, are interdependent. (i.e they can read, write or change another threads data)
Eg: Opening two different browsers. Eg: Opening two tabs in the same browser.

Threads vs Process

Concurrency vs Parallelism

  • Concurrent - At the same time, but not necessarily at the same instant. It is possible for multiple threads to be at different stages of execution at the same time but not being processed together. A single core CPU can only execute one thread at a time. But it can switch between threads very quickly. This is called context switching. This is how concurrency is achieved. A single core CPU can have concurrency but not parallelism.
  • Parallel - At the same time and at the same instant. It is possible for multiple threads to be at different stages of execution at the same time and being processed together. A single core CPU cannot achieve parallelism. It can only achieve concurrency. A multi-core CPU can achieve both concurrency and parallelism.

Using threads in Java

In Java, we can create a thread by extending the Thread class or by implementing the Runnable interface. The Thread class is a subclass of the Object class. It implements the Runnable interface. The Runnable interface has a single method called run(). This method is called when the thread is started.

class NewThread implements Runnable {
    @Override
    public void run() {
        // Code to be executed by the thread
    }
}

We can create a new thread by creating an object of the NewThread class and passing it to the Thread class constructor. The Thread class constructor takes a Runnable object as an argument. This Runnable object is the thread that we want to create.

NewThread newThread = new NewThread();
Thread thread = new Thread(newThread);

To run the thread, we call the start() method on the Thread object. This method calls the run() method of the Runnable object. The run() method is executed by the thread.

thread.start();

Number printer

Problem Statement

  • Create a new thread that prints the numbers from 1 to 10.

Solution

class NumberPrinter implements Runnable {
    @Override
    public void run() {
        for (int i = 1; i <= 10; i++) {
            System.out.println(i);
        }
    }
}
public class Main {
    public static void main(String[] args) {
        NumberPrinter numberPrinter = new NumberPrinter();
        Thread thread = new Thread(numberPrinter);
        thread.start();
    }
}

Problem Statement 2

  • Print the numbers from 1 to 10 where each number is printed by a different thread.

Solution

class NumberPrinter implements Runnable {
    private int number;

    public NumberPrinter(int number) {
        this.number = number;
    }

    @Override
    public void run() {
        System.out.println(number);
    }
}
public class Main {
    public static void main(String[] args) {
        for (int i = 1; i <= 10; i++) {
            NumberPrinter numberPrinter = new NumberPrinter(i);
            Thread thread = new Thread(numberPrinter);
            thread.start();
        }
    }
}

Executor

The Executor interface is used to execute tasks. It is a generic interface that can be used to execute any kind of task. The Executor interface has only one method:

public interface Executor {
    void execute(Runnable command);
}

The execute method takes a Runnable object as a parameter. The Runnable interface is a functional interface that has only one method. Executors internally use a thread pool to execute the tasks. The execute method is non-blocking. It returns immediately after submitting the task to the thread pool. The execute method is used to execute tasks that do not return a result.

A thread pool is a collection of threads that are used to execute tasks. Instead of creating a new thread for each task, a thread pool reuses the existing threads to execute the tasks. This improves the performance of the application.

The Executor interface has a method called newCachedThreadPool that returns an ExecutorService object. The ExecutorService interface extends the Executor interface. The ExecutorService interface has methods to execute tasks that return a result. The ExecutorService interface also has methods to shutdown the thread pool.

To run a task using the Executor interface, we can use the newCachedThreadPool method to create an ExecutorService object. The newCachedThreadPool method returns an ExecutorService object that uses a thread pool with a variable number of threads. The newCachedThreadPool method creates a new thread for each task if there are no idle threads in the thread pool. If there is an idle thread in the thread pool, the newCachedThreadPool method reuses the idle thread to execute the task. The newCachedThreadPool method returns an ExecutorService object that uses a thread pool with a variable number of threads.

Executor executorService = Executors.newCachedThreadPool();
executorService.execute(() -> System.out.println("Hello World"));

Callable and Future

Runnables do not return a result. If we want to execute a task that returns a result, we can use the Callable interface. The Callable interface is a functional interface that has only one method:

public interface Callable<V> {
    V call() throws Exception;
}

The call method returns a result of type V. The call method can throw an exception. The Callable interface is used to execute tasks that return a result. For instance we can use the Callable interface to execute a task that returns the sum of two numbers:

Callable<Integer> sumTask = () -> 2 + 3;

In order to execute a task that returns a result, we can use the submit method of the ExecutorService interface. The submit method takes a Callable object as a parameter. The submit method returns a Future object. The Future interface has a method called get that returns the result of the task. The get method is a blocking method. It waits until the task is completed and then returns the result of the task.

ExecutorService executorService = Executors.newCachedThreadPool();
Future<Integer> future = executorService.submit(() -> 2 + 3);
Integer result = future.get();

Futures can be used to cancel tasks. The Future interface has a method called cancel that can be used to cancel a task. The cancel method takes a boolean parameter. If the boolean parameter is true, the task is cancelled even if the task is already running. If the boolean parameter is false, the task is cancelled only if the task is not running.

ExecutorService executorService = Executors.newCachedThreadPool();
Future<Integer> future = executorService.submit(() -> 2 + 3);
future.cancel(false);

Assignment

  • Create a count class that has a count variable.
  • Create two different classes Adder and Subtractor.
  • Accept a count object in the constructor of both the classes.
  • In Adder, iterate from 1 to 100 and increment the count variable by 1 on each iteration.
  • In Subtractor, iterate from 1 to 100 and decrement the count variable by 1 on each iteration.
  • Print the final value of the count variable.
  • What would the ideal value of the count variable be?
  • What is the actual value of the count variable?
  • Try to add some delay in the Adder and Subtractor classes using inspiration from the code below. What is the value of the count variable now?
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    e.printStackTrace();
}

Reading List