Skip to content

IPC Implementation Rewrite #114

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

maxtyson123
Copy link
Contributor

resolves #113

As discussed in Issue #113 the current IPC message passing tutorial may be hard to follow for some users and the overall design could be reworked to be better.

This PR proposes a improved design:

  • Requires only 2 syscalls instead of 3
  • Prevents a userspace program dictating when the buffer can be freed (see 36.1 How It Works - final bullet point)
  • Allows for multiple messages
  • More freedom for the process that owns the endpoint to handle messages how it deems fit for it's usecase

(This IPC mecanhisim was a combination of the current one in the book and my implementation in Max OS

@DeanoBurrito
Copy link
Member

Thanks for this, I'll take a look later today.

@dreamos82
Copy link
Member

LGTM.
Let's wait for @DeanoBurrito review.

@DeanoBurrito
Copy link
Member

sorry for the delay, looking at this now. Time works differently where I'm from 😅

Copy link
Member

@DeanoBurrito DeanoBurrito left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Writing is good overall, but there's a couple of design issues I see:

  1. The kernel is accessing the userspace heap (see the distinction you made about kmalloc() vs malloc())? This is a bad idea, as the kernel now relies on user code to 'do the right thing' and provide a working malloc() to allocate the recipients message buffers. Or alternatively the kernel has to be involved in userspace heap management (the user program might not even have a heap, or may have multiple, or some per-thread caches - how do you deal with that on the kernel side?).

This is a tricky one to deal with, the common approach is that userspace allocates the memory and informs the kernel of where the buffer is located as part of a system call. This is where a dedicated ipc_receive() function is handy, because the recipient can pass the buffer they wish the message to be copied into. You could also have a big block of memory attached to the endpoint and have that act as shared memory between the kernel and receiving process (this would need some synchronization if the receiving process isnt blocking on reads).

  1. Similar to 1, this version of message parsing combines IPC concepts and userspace/kernel interactions into one chapter. In a real implementation there may be some overlap, but I think it muddies the waters when it comes to explaining something new. It would be better to break this down into smaller ideas: how to move messages between two address spaces (all of this happens in the kernel, this is what the original message parsing example did), and then the API presented to userspace (not previously covered).

- _Process 1_ wants to receive incoming messages on an endpoint, so it calls a function telling the kernel to create an endpoint in our IPC manager. This function will setup and return a block of (userspace) memory containing a message queue. We'll call this function `create_endpoint()`.
- _Process 2_ wants to send a message sometime later, so it allocates a buffer and writes some data there.
- _Process 2_ now calls a function to tell the kernel it wants to send this buffer as a message to an endpoint. We'll call this function `ipc_send()`.
- Inside `ipc_send()` the buffer is copied into kernel memory. In our example, we'll use the heap for this memory. We can then switch to process 1's address space and copy the buffer on the heap into the queue.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copy the buffer on the heap into the queue.
is a little ambiguous, I think relying less on context would clean this up:
copy the kernel's buffer into the endpoint's queue.

uintptr_t next_message;
} ipc_message_t;

typedef struct ipc_message_queue{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this type needed? since an endpoints and queues have a one-to-one relationship I dont think this is necessary. Instead embedding the list head in ipc_endpoint should be fine.

void* msg_buffer;
size_t msg_length;
ipc_message_queue_t* queue;
uint64_t owner_pid;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer tid_t over uint64_t here, it describes intent better. uint64_t may not always be a convenient type depending on the architecture.

- What happens if there unread messages when destroying an endpoint? How do you handle them?
- Who is allowed to remove an endpoint?
- What happens if there are unread messages when destroying an endpoint? How do you handle them?
- Who is allowed to remove an endpoint? (`owner_pid` would be useful here)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about:

(hint: owner_pid is useful here)


In theory this works, but we've overlooked one huge issue: what if there's already a message at the endpoint? You should handle this, and there's a couple of ways to go about it:
// Add the message to the queue
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extra space after Add


In theory this works, but we've overlooked one huge issue: what if there's already a message at the endpoint? You should handle this, and there's a couple of ways to go about it:
// Add the message to the queue
// Left for the reader to do, trivial linked list appending
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this reads like a note I'd leave for myself rather than a hint. What about:

// append message struct to the endpoint's queue, implementation is left as an exercise for the reader.

- We've described a double-copy implementation here, but you might want to try a single-copy implementation. Single-copy implementations *can* be faster, but they require extra logic. For example the kernel will need to access the recipient's address space from the sender's address space, how do you manage this? If you have all of physical memory mapped somewhere (like an identity map, or direct map (HHDM)) you could use this, otherwise you will need some way to access this memory.
- A process waiting on an endpoint (to either send or receive a message) could be waiting quite a while in some circumstances. This is time the cpu could be doing work instead of blocking and spinning on a lock. A simple optimization would be to put the thread to sleep, and have it be woken up whenever the endpoint is updated: a new message is sent, or the current message is read.
- In this example we've allowed for messages of any size to be sent to an endpoint, but you may want to set a maximum message size for each endpoint when creating it. This makes it easier to receive messages as you know the maximum possible size the message can be, and can allocate a buffer without checking the size of the message. This might seem silly, but when receiving a message from userspace the program has to make a system call each time it wants the kernel to do something. Having a maximum size allows for one-less system call. Enforcing a maximum size for messages also has security benefits.
- We've described a double-copy implementation here, but you might want to try a single-copy implementation. Single-copy implementations *can* be faster, but they require extra logic. For example, the kernel will need to access the recipient's address space from the sender's address space, how do you manage this? If you have all of the physical memory mapped somewhere (like an identity map, or direct map (HHDM)) you could use this, otherwise, you will need some way to access this memory.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

, otherwise,
Too many commas, the second one can be omitted I think.

- In this example we've allowed for messages of any size to be sent to an endpoint, but you may want to set a maximum message size for each endpoint when creating it. This makes it easier to receive messages as you know the maximum possible size the message can be, and can allocate a buffer without checking the size of the message. This might seem silly, but when receiving a message from userspace the program has to make a system call each time it wants the kernel to do something. Having a maximum size allows for one-less system call. Enforcing a maximum size for messages also has security benefits.
- We've described a double-copy implementation here, but you might want to try a single-copy implementation. Single-copy implementations *can* be faster, but they require extra logic. For example, the kernel will need to access the recipient's address space from the sender's address space, how do you manage this? If you have all of the physical memory mapped somewhere (like an identity map, or direct map (HHDM)) you could use this, otherwise, you will need some way to access this memory.
- A process waiting on an endpoint (to either send or receive a message) could be waiting quite a while in some circumstances. This is a time when the cpu could be doing work instead of blocking and spinning on a lock. A simple optimization would be to put the thread to sleep, and have it be woken up whenever the endpoint is updated: a new message is sent, or the current message is read.
- In this example we've allowed for messages of any size to be sent to an endpoint, but you may want to set a maximum message size for each endpoint when creating it. This makes it easier to receive messages as you know the maximum possible size the message can be, and can allocate a buffer without checking the size of the message. This might seem silly, but when receiving a message from userspace the program has to make a system call each time it wants the kernel to do something. Having a maximum size allows for one less system call. Enforcing a maximum size for messages also has security benefits.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the point about saving a system call is irrelevant for this implementation, since the message contents and metadata are already in the recipients address space. There's no system call in the example you give your receiving messages.
The rest is good.

@dreamos82
Copy link
Member

@maxtyson123 any news on this PR? Do you want to make the changes requested?

@IAmTheNerdNextDoor
Copy link
Contributor

@maxtyson123 any news on this PR? Do you want to make the changes requested?

and he never responded...

@maxtyson123
Copy link
Contributor Author

maxtyson123 commented Apr 27, 2025

Sorry didn't see the earlier messages. Quite busy right now but I can begin working on it when I have spare time.

@dreamos82
Copy link
Member

dreamos82 commented Apr 27, 2025

es. Quite busy right now but I can be

Perfect thanks! :) Just let us know in case you don't have time at all to finish it, and you want someone else to take it over.

@maxtyson123
Copy link
Contributor Author

Just to be clear before I begin working on this:

  1. Now that you mention it, I do agree that it wasn't the best practice, albeit for different reasons. In Max Os each process has its own Memory Manager that handles that process's memory (Note: this may change when I work on actually implementing threads). I now realise that my suggested method "locks" the reader into my design of managing memory in the kernel and in that way. How would you suggest this is fixed, going back to the 'read()' design or something new?
  2. It should be broken up into two sections. One subsection of the "Userspace" section for resource management, detailing HOW to move information between address spaces. Note: I haven't finished my filesystem implementation (what I'm working on for Max Os now) so if we want to address files in there aswell (which I think mat be a good idea) I will need to finish that first. Separately, in the IPC section, the code should be changed to build on top of this API.

P.S. Is there a better place to talk with easier back and forth? That way we can keep the public github discussion to what is actually relevant to the PR and can also have a method smaller things

@DeanoBurrito
Copy link
Member

Hey Max, all good - life is like that sometimes. Glad to see you're still interested in this :)
We have a discord server we hang out in (I'm a little absent myself these days, but I do eventually get around to responding to messages). Here's the invite: https://discord.gg/X5YmgDKW

As for your questions:

  1. That's pretty standard to have a 'memory manager' (in this case its your VirtualMemoryManager class) per address space, which is usually linked one-to-one with a process. A number of threads would share the address space of the process, so that all makes sense. I'm not sure about the purpose of your MemoryManager class though, it looks like it allocates memory for a heap in the address space it's attached to (so far so good), but never maps the pages as user-accessible, so you have a kernel heap in the higher half and then one per address space in the lower half if I understand correctly? This sounds like it's breaking the 'lower half is for userspace' idea. So my suggestion there is to remove the lower-half kernel heaps, keep kernel stuff in the higher half, and then yeah as you mentioned - going back to the read() design. Or something similar where userspace passes the buffer to the kernel.

The other big benefit to the read() approach is the user thread can block until there's a new message, or rather you can see it as read() will only return when there's a new message to be processed (ignoring edge cases where the read times out, or is cancelled). If the user thread only wants to poll, you can support that to, and in that case it's like the section you wrote where it checks for a new message and if there's none it carries on with other work.

  1. Yeah that division sounds good. It'd be good to focus on providing general theory first for exposing kernel resources to userspace (via handles/file descriptors/objects/your preferred terminology), and how you might go about moving data in and out of those resources. That framework could then easily be applied to files, ipc and other things.
    Personally I'd like to keep the focus of the userspace chapter on dealing with userspace, so I wouldnt object to some small examples of how this might be used - but I think it's better to keep file access related stuff to the vfs chapter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

IPC Messsage Passing
5 participants