io_service/io_context are designed to stop when they run out of work¹.
The docs for io_service and io_context contain:
Stopping the io_context from running out of work
Some applications may need to prevent an io_context object's run() call from returning when there is no more work to do. For example, the io_context may be being run in a background thread that is launched prior to the application's asynchronous operations. The run() call may be kept running by creating an object of type boost::asio::executor_work_guard<io_context::executor_type>:
boost::asio::io_context io_context;
boost::asio::executor_work_guard<boost::asio::io_context::executor_type>
= boost::asio::make_work_guard(io_context);
...
To effect a shutdown, the application will then need to call the io_context object's stop() member function. This will cause the io_context run() call to return as soon as possible, abandoning unfinished operations and without permitting ready handlers to be dispatched.
Alternatively, if the application requires that all operations and handlers be allowed to finish normally, the work object may be explicitly reset.
boost::asio::io_context io_context;
boost::asio::executor_work_guard<boost::asio::io_context::executor_type>
= boost::asio::make_work_guard(io_context);
...
work.reset(); // Allow run() to exit.
Note that the "old-fashioned" Asio interface used a less generalized io_service::work object:
io_service ios;
io_service::work work(ios); // old interface!
This would then require you to do extra work for to be able to reset it:
asio::io_service ios;
std::optional<asio::io_service::work> work(ios);
// ...
work.reset();
Restarting
Finally when the context did run out of work, you will have to restart() it before re-using it:

Rationale
I think the rationale for the design comes from the library not having any
opinions about how the service is run in terms of scheduling and threading, in
combination with the guarantee that io_context/io_service must be
thread-safe². See docs for
background.
¹ Sidenote: Likewise, thread_pool (which is-a execution_context just like io_context is) is not designed for re-use (see e.g. Boost asio thread_pool join does not wait for tasks to be finished)
² Except object lifetime (construction/destruction), of course