I'm sorry that OS class didn't cover this topic. It really should have. When I need to get into details I'm going to refer to Linux since it's both open-source and very popular, but other operating systems (as long as they have preemptive multitasking) are similar.
You're probably familiar with the PC hardware term "hardware thread". Linux calls them "CPUs". I'm also going to use "sleep" to talk about situations where hardware is saving power while waiting - this includes any time a CPU isn't 100% utilized.
At any time, a CPU can be be doing one of several things:
instructions triggered by an interrupt event [
instructions needed to provide kernel services, such as translating between file-based IO and the storage devices. [
application or server instructions in user mode [
waiting for something to happen [
instructions outside Linux's control [
[the codes are seen in the
Interrupts are the key to understanding this. Whenever a hardware device needs attention from its driver, it sends a message to the interrupt controller, which routes it to a CPU, which makes a note of what it was doing and switches to the instructions for the corresponding device driver. Interrupts can also be generated by timer hardware or triggered by software. For example, when the CPU scheduler running on CPU 2 notices that CPU 5 should wake up or switch to a different task, it will send an interrupt.
There's an instruction which causes a CPU to do nothing and save power until it receives an interrupt. Linux creates an "idle task", which uses this instruction inside a loop - along with other things. The Linux idle loop also checks to see if a task is ready to run and may turn the "jiffy" timer off while a CPU is sleeping or do other power-management tasks.
Then that CPU's sleep is controlled by switching into and out of the idle task. Each CPU has one.
There's utility hardware on the CPU die which controls how much of a core is shut down while it is asleep - instructions on CPU 2 can control what CPU 5 is doing, etc. This is managed using ACPI firmware, and the different modes of power saving are called ACPI C-states. If the sleep is shallow enough then that core can still receive interrupts. So Linux also needs to tell the interrupt controller which cores are available for interrupts.
Like I said, interrupts are the key. Something happens in hardware - a block of data is ready from the hard drive, the USB controller is ready for more data, packets have arrived via Ethernet, time has elapsed, the GPU needs a chunk of memory - and a CPU core responds. If the CPU was lightly sleeping, that interrupt will wake it up. The device driver will run, the CPU will go back to its idle loop, and it check if there's a task ready to run. If so, the CPU will switch to that task and now it's running application instructions.
The reverse process, going to sleep, happens when the application asks for something and asks to go to sleep if necessary. For example, your web browser might ask if data has arrived from the network or input has been received from the user interface. It does this by a special instruction that switches to kernel mode. Linux is then running with privileges necessary to see the entire system. It checks for "ready-for-input" state. If ready, it returns to the browser's code, which continues receiving and processing the input.
But if Linux detects that an application isn't ready, it makes a note of what services the application is waiting for, marks the task as sleeping, and looks for some other task to do. If it doesn't find anything, it switches to the idle task, which is responsible for putting that CPU to sleep.
Later, when an interrupt happens and device drivers run, those notes will identify that the browser is ready to run again.
There are also code paths that decide whether to move tasks to a different CPU, change the sleep state of different CPU, and wake up deeply sleeping ones, all depending on load. Simple computers with only one CPU don't need that complexity, but PCs are generally multicore now.
So to recap:
the operating system puts software to sleep when that software needs something but that thing isn't ready or hasn't happened yet. It then tries to find some other software to run.
this can only happen when software asks the OS for something (a system-call). If an application enters an endless loop, it will use all the CPU time the OS gives it.
if there isn't any software for a CPU to run, the OS uses its idle task to put it to sleep.
(Switching to an idle task means that the previous task is free to move to another CPU if necessary - it isn't loaded in the CPU stuck there doing nothing. A single-CPU OS might not need an idle task, but it's still typically used.)
when hardware needs something or data arrives that creates an interrupt. This either interrupts a running CPU or wakes up a lightly sleeping one, and causes a device driver to run. At this point, OS book-keeping notices which sleeping software is now ready to run. At least one CPU is awake at this point - the one running the device driver - and others may be awoken if the OS desires. The OS decides which task should run (CPU scheduling) and switches if necessary.
if an application is interrupted, the OS will remember what it was doing and will eventually switch back to it.
if multiple tasks are ready to run at once, a timer interrupt (Linux calls it the "jiffy") will eventually cause the OS to switch tasks, even if there are no other interrupts on that CPU.
if an event is noticed on CPU A which means CPU B should wake up or might need to switch tasks, the OS running on A sends an interrupt to itself on B. In unusual situations, like forcing software to stop, CPU B might even be interrupted to tell it to go to sleep.
a "not-yet-ready" condition inside a system call puts software to sleep, hardware interrupts or software events cause hardware and software to wake up.
it's also possible to force software to sleep. Linux has "stop/trace" which is mostly used to pause software for debugging and "freeze" for making the entire system pause and save power. Both are triggered by software events, such as a debugger launching a program or the user clicking a "suspend to RAM" button.