diff --git a/arch/x86/include/arch/sched.h b/arch/x86/include/arch/sched.h
new file mode 100644
index 0000000..c62d204
--- /dev/null
+++ b/arch/x86/include/arch/sched.h
@@ -0,0 +1,56 @@
+/* See the end of this file for copyright and license terms. */
+
+#pragma once
+
+#include <gay/cdefs.h>
+#include <gay/types.h>
+
+/**
+ * @brief In-kernel context save for the x86.
+ * This precise structure layout is hardcoded in assembly, so don't forget to
+ * update `arch/x86/sys/switch.S` if you need to change it for whatever reason.
+ */
+struct x86_context {
+	/**
+	 * The register itself is %esp, which points to the %eip that was pushed
+	 * by the function calling `arch_switch_to()` as per the x86 SysV ABI
+	 */
+	union {
+		register_t esp;
+		register_t *eip;
+	};
+	register_t esi;
+	register_t edi;
+	register_t ebx;
+	register_t ebp;
+} __packed;
+
+/**
+ * @brief Arch dependent Task Control Block (x86 version).
+ * This is what's required for in-kernel task switching.
+ * Treat as a completely opaque type outside of the `arch/x86` directory.
+ */
+typedef struct x86_context tcb_t;
+
+/**
+ * @brief Arch dependent low level task switch routine (x86 version).
+ * `new` must not be equal to `old` or the whole thing probably blows up.
+ *
+ * @param new TCB of the new task we are switching to
+ * @param old TCB of the old task we are switching from
+ */
+extern void arch_switch_to(tcb_t *new, tcb_t *old);
+
+/*
+ * This file is part of GayBSD.
+ * Copyright (c) 2021 fef <owo@fef.moe>.
+ *
+ * GayBSD is nonviolent software: you may only use, redistribute, and/or
+ * modify it under the terms of the Cooperative Nonviolent Public License
+ * (CNPL) as found in the LICENSE file in the source code root directory
+ * or at <https://git.pixie.town/thufie/npl-builder>; either version 7
+ * of the license, or (at your option) any later version.
+ *
+ * GayBSD comes with ABSOLUTELY NO WARRANTY, to the extent
+ * permitted by applicable law.  See the CNPL for details.
+ */
diff --git a/arch/x86/sys/CMakeLists.txt b/arch/x86/sys/CMakeLists.txt
index c979f27..f033599 100644
--- a/arch/x86/sys/CMakeLists.txt
+++ b/arch/x86/sys/CMakeLists.txt
@@ -6,6 +6,7 @@ target_sources(gay_arch PRIVATE
     irq.c
     irq.S
     port.S
+    switch.S
     trap.c
     trap.S
 )
diff --git a/arch/x86/sys/switch.S b/arch/x86/sys/switch.S
new file mode 100644
index 0000000..dc3ec2d
--- /dev/null
+++ b/arch/x86/sys/switch.S
@@ -0,0 +1,73 @@
+/* See the end of this file for copyright and license terms. */
+
+#include <asm/common.h>
+
+/*
+ * Alright, a lot of stuff that is not immediately obvious to someone who hasn't
+ * done this sort of thing before is going on here, and since i'm totally new to
+ * x86 myself, here is some excessive documentation of the entire process (which
+ * will hopefully also help other newcomers in understanding the actual switching
+ * mechanism).  I think the main reason this particular function might seem a
+ * little confusing is that it returns to a different place than where it came
+ * from, which is kind of the whole point if you think about it.
+ *
+ * This routine is called from within kernel space, and will perform a switch to
+ * another task that also runs in kernel space.  So, this has nothing to do with
+ * changing ring levels.  When another task switches back to the original task,
+ * that original task just continues execution as if it had never called this
+ * function.
+ * As per the x86 SysV ABI, procedure calling is done by pushing all arguments
+ * to the stack in reverse order, and then use the `call` instruction which
+ * additionally pushes %eip to the stack and jumps to the subroutine's address.
+ * So, when we enter this method, the stack looks like this (remember, this is a
+ * full descending stack):
+ *
+ * | address | value            |
+ * +---------+------------------+
+ * | 8(%esp) | old (argument 2) |
+ * | 4(%esp) | new (argument 1) |
+ * |  (%esp) | caller's %eip    |
+ *
+ * What we need to do now is store all caller saved registers (which critically
+ * include the stack pointer) into `old`, set their values to the ones from
+ * `new` (again, including the stack pointer) and then just return.
+ * The new stack pointer will point to the same stack layout shown above, but
+ * this time that of the new task we are going to switch to.  Since the stack
+ * also includes the %eip from when the new task called arch_switch_to(), we
+ * automatically switch to that task when returning.
+ */
+
+	.text
+
+/* void arch_switch_to(tcb_t *new, tcb_t *old); */
+ASM_ENTRY(arch_switch_to)
+	mov	8(%esp), %eax	/* %eax = old */
+	mov	%esp, (%eax)
+	mov	%esi, 4(%eax)
+	mov	%edi, 8(%eax)
+	mov	%ebx, 12(%eax)
+	mov	%ebp, 16(%eax)
+
+	mov	4(%esp), %eax	/* %eax = new */
+	mov	(%eax), %esp
+	mov	4(%eax), %esi
+	mov	8(%eax), %edi
+	mov	12(%eax), %ebx
+	mov	16(%eax), %ebp
+
+	ret
+ASM_END(arch_switch_to)
+
+/*
+ * This file is part of GayBSD.
+ * Copyright (c) 2021 fef <owo@fef.moe>.
+ *
+ * GayBSD is nonviolent software: you may only use, redistribute, and/or
+ * modify it under the terms of the Cooperative Nonviolent Public License
+ * (CNPL) as found in the LICENSE file in the source code root directory
+ * or at <https://git.pixie.town/thufie/npl-builder>; either version 7
+ * of the license, or (at your option) any later version.
+ *
+ * GayBSD comes with ABSOLUTELY NO WARRANTY, to the extent
+ * permitted by applicable law.  See the CNPL for details.
+ */
diff --git a/include/gay/clist.h b/include/gay/clist.h
index ffc7d4b..34e39e1 100644
--- a/include/gay/clist.h
+++ b/include/gay/clist.h
@@ -61,6 +61,14 @@ void clist_add_first(struct clist *head, struct clist *new);
  */
 void clist_del(struct clist *node);
 
+/**
+ * @brief Return whether a clist is empty.
+ *
+ * @param head The `struct clist *` acting as the head of the clist
+ * @returns An expression that evaluates true if the list is empty
+ */
+#define clist_is_empty(head) ((head)->next == (head))
+
 /**
  * @brief Iterate over every node in a clist.
  *
diff --git a/include/gay/sched.h b/include/gay/sched.h
new file mode 100644
index 0000000..aa37122
--- /dev/null
+++ b/include/gay/sched.h
@@ -0,0 +1,94 @@
+/* See the end of this file for copyright and license terms. */
+
+#pragma once
+
+#include <arch/sched.h>
+
+#include <gay/cdefs.h>
+#include <gay/clist.h>
+#include <gay/types.h>
+
+enum task_state {
+	TASK_READY,
+	TASK_BLOCKED,
+	TASK_DEAD,
+};
+
+struct task {
+	/**
+	 * @brief List node for enqueuing this task in the scheduler's pipeline.
+	 * When the task is currently running, this is invalid because the task
+	 * is not in any queue.
+	 */
+	struct clist run_queue;
+	tcb_t tcb;
+	struct task *parent;
+	pid_t pid;
+	enum task_state state;
+};
+
+/**
+ * @brief The task that is currently running on this particular cpu core.
+ * May not be accessed from irq context.
+ *
+ * This will become a macro which calls some function once we have SMP support,
+ * so if you need to access it multiple times from the same place you should
+ * copy it into a local variable first to avoid extra overhead.
+ *
+ * For the same reason, don't assign values to this directly, and use
+ * `set_current()` instead.
+ */
+extern struct task *current;
+
+/**
+ * @brief Update the `current` task.
+ * You will almost never need this because `switch_to()` does it automatically.
+ */
+#define set_current(task) (current = (task))
+
+extern struct task kernel_task;
+
+/**
+ * @brief Initialize the scheduler.
+ *
+ * @return 0 on success, or a negative error number on failure
+ */
+int sched_init(void);
+
+/**
+ * @brief Main scheduler routine.
+ * Calling this function will result in the current task being suspended and put
+ * back to the queue, and a new one is selected to be run next.  The scheduler
+ * is free to choose the current task again, in which case this call does not
+ * sleep.  The function returns when the scheduler has been invoked again and
+ * decided to give this task its next turn.
+ */
+void schedule(void);
+
+/**
+ * @brief Perform an in-kernel task switch.
+ * `new` must not be equal to `old` or the whole thing probably blows up.
+ *
+ * The Linux kernel has a similar function with the same name, except that the
+ * arguments are in reverse order because i find it funny to cause unnecessary
+ * confusion which will undoubtedly result in a lot of bugs further down the
+ * road that are going to take weeks to find.
+ *
+ * @param new New task to switch to
+ * @param old Current task
+ */
+void switch_to(struct task *new, struct task *old);
+
+/*
+ * This file is part of GayBSD.
+ * Copyright (c) 2021 fef <owo@fef.moe>.
+ *
+ * GayBSD is nonviolent software: you may only use, redistribute, and/or
+ * modify it under the terms of the Cooperative Nonviolent Public License
+ * (CNPL) as found in the LICENSE file in the source code root directory
+ * or at <https://git.pixie.town/thufie/npl-builder>; either version 7
+ * of the license, or (at your option) any later version.
+ *
+ * GayBSD comes with ABSOLUTELY NO WARRANTY, to the extent
+ * permitted by applicable law.  See the CNPL for details.
+ */
diff --git a/include/gay/types.h b/include/gay/types.h
index 54b776b..f97e813 100644
--- a/include/gay/types.h
+++ b/include/gay/types.h
@@ -91,6 +91,11 @@ typedef __register_t		register_t;
 typedef __u_register_t		u_register_t;
 #endif /* not _U_REGISTER_T_DECLARED */
 
+#ifndef _PID_T_DECLARED
+#define _PID_T_DECLARED 1
+typedef int			pid_t;
+#endif /* not _PID_T_DECLARED */
+
 /*
  * This file is part of GayBSD.
  * Copyright (c) 2021 fef <owo@fef.moe>.
diff --git a/kernel/CMakeLists.txt b/kernel/CMakeLists.txt
index 64735e7..6bc3ee1 100644
--- a/kernel/CMakeLists.txt
+++ b/kernel/CMakeLists.txt
@@ -11,6 +11,7 @@ target_sources(gay_kernel PRIVATE
     irq.c
     kprintf.c
     main.c
+    sched.c
     util.c
 )
 
diff --git a/kernel/main.c b/kernel/main.c
index e5c790a..6e710a2 100644
--- a/kernel/main.c
+++ b/kernel/main.c
@@ -1,6 +1,7 @@
 /* See the end of this file for copyright and license terms. */
 
 #include <gay/irq.h>
+#include <gay/sched.h>
 
 /**
  * @brief Main kernel entry point.
@@ -15,7 +16,14 @@
  */
 int main(int argc, char *argv[])
 {
+	int err;
+
 	irq_init();
+
+	err = sched_init();
+	if (err)
+		return err;
+
 	return 0;
 }
 
diff --git a/kernel/sched.c b/kernel/sched.c
new file mode 100644
index 0000000..c5270f1
--- /dev/null
+++ b/kernel/sched.c
@@ -0,0 +1,95 @@
+/* See the end of this file for copyright and license terms. */
+
+#include <arch/interrupt.h>
+#include <arch/sched.h>
+
+#include <gay/clist.h>
+#include <gay/sched.h>
+#include <gay/types.h>
+
+struct task kernel_task = {
+	.parent = nil,
+	.pid = 0,
+	.state = TASK_READY,
+};
+
+struct task *current = &kernel_task;
+
+/*
+ * Two run queues, one active and one inactive.  The scheduler takes tasks out
+ * of the active queue, runs them if possible, and then puts them into the
+ * inactive queue if they aren't dead.  When the active queue gets empty, the
+ * queues are switched and everything starts over again.
+ * The `current` task is always in neither queue.
+ */
+static struct clist _run_queues[2];
+static struct clist *active_queue = &_run_queues[0];
+static struct clist *inactive_queue = &_run_queues[1];
+
+int sched_init(void)
+{
+	clist_init(active_queue);
+	clist_init(inactive_queue);
+	return 0;
+}
+
+void schedule(void)
+{
+	struct task *old = current;
+	struct task *new = nil;
+
+	clist_add(inactive_queue, &old->run_queue);
+
+/* Dijkstra would be so mad if he saw this */
+try_again:
+	if (clist_is_empty(active_queue)) {
+		struct clist *tmp = active_queue;
+		active_queue = inactive_queue;
+		inactive_queue = tmp;
+	}
+
+	struct task *cursor, *tmp;
+	clist_foreach_entry_safe(active_queue, cursor, tmp, run_queue) {
+		clist_del(&cursor->run_queue);
+		if (cursor->state == TASK_READY) {
+			new = cursor;
+			break;
+		} else if (cursor->state == TASK_BLOCKED) {
+			clist_add(inactive_queue, &cursor->run_queue);
+		}
+	}
+
+	/*
+	 * This should really make the CPU go idle rather than constantly
+	 * looping around until some event occurs, but i have no idea how to do
+	 * that (yet)
+	 */
+	if (new == nil)
+		goto try_again;
+
+	if (new != old)
+		switch_to(new, old);
+}
+
+void switch_to(struct task *new, struct task *old)
+{
+	disable_intr();
+	current = new;
+	arch_switch_to(&new->tcb, &old->tcb);
+	current = old;
+	enable_intr();
+}
+
+/*
+ * This file is part of GayBSD.
+ * Copyright (c) 2021 fef <owo@fef.moe>.
+ *
+ * GayBSD is nonviolent software: you may only use, redistribute, and/or
+ * modify it under the terms of the Cooperative Nonviolent Public License
+ * (CNPL) as found in the LICENSE file in the source code root directory
+ * or at <https://git.pixie.town/thufie/npl-builder>; either version 7
+ * of the license, or (at your option) any later version.
+ *
+ * GayBSD comes with ABSOLUTELY NO WARRANTY, to the extent
+ * permitted by applicable law.  See the CNPL for details.
+ */