I think that the following text from the zshell's code is an interesting read on the subject:
TL;DR: Another strategy is to "migrate" the while-loop to a child process the moment that you hit ^Z, but, this is really hard (maybe impossible?) to do correctly. (From Src/exec.c) /* * [...] * * In most shells, if you do something like: * * cat foo | while read a; do grep $a bar; done * * the shell forks and executes the loop in the sub-shell thus created. * In zsh this traditionally executes the loop in the current shell, which * is nice to have if the loop does something to change the shell, like * setting parameters or calling builtins. * Putting the loop in a sub-shell makes life easy, because the shell only * has to put it into the job-structure and then treats it as a normal * process. Suspending and interrupting is no problem then. * Some years ago, zsh either couldn't suspend such things at all, or * it got really messed up when users tried to do it. As a solution, we * implemented the list_pipe-stuff, which has since then become a reason * for many nightmares. * Pipelines like the one above are executed by the functions in this file * which call each other (and sometimes recursively). The one above, for * example would lead to a function call stack roughly like: * * execlist->execpline->execcmd->execwhile->execlist->execpline * * (when waiting for the grep, ignoring execpline2 for now). At this time, * zsh has built two job-table entries for it: one for the cat and one for * the grep. If the user hits ^Z at this point (and jobbing is used), the * shell is notified that the grep was suspended. The list_pipe flag is * used to tell the execpline where it was waiting that it was in a pipeline * with a shell construct at the end (which may also be a shell function or * several other things). When zsh sees the suspended grep, it forks to let * the sub-shell execute the rest of the while loop. The parent shell walks * up in the function call stack to the first execpline. There it has to find * out that it has just forked and then has to add information about the sub- * shell (its pid and the text for it) in the job entry of the cat. The pid * is passed down in the list_pipe_pid variable. * But there is a problem: the suspended grep is a child of the parent shell * and can't be adopted by the sub-shell. So the parent shell also has to * keep the information about this process (more precisely: this pipeline) * by keeping the job table entry it created for it. The fact that there * are two jobs which have to be treated together is remembered by setting * the STAT_SUPERJOB flag in the entry for the cat-job (which now also * contains a process-entry for the whole loop -- the sub-shell) and by * setting STAT_SUBJOB in the job of the grep-job. With that we can keep * sub-jobs from being displayed and we can handle an fg/bg on the super- * job correctly. When the super-job is continued, the shell also wakes up * the sub-job. But then, the grep will exit sometime. Now the parent shell * has to remember not to try to wake it up again (in case of another ^Z). * It also has to wake up the sub-shell (which suspended itself immediately * after creation), so that the rest of the loop is executed by it. * But there is more: when the sub-shell is created, the cat may already * have exited, so we can't put the sub-shell in the process group of it. * In this case, we put the sub-shell in the process group of the parent * shell and in any case, the sub-shell has to put all commands executed * by it into its own process group, because only this way the parent * shell can control them since it only knows the process group of the sub- * shell. Of course, this information is also important when putting a job * in the foreground, where we have to attach its process group to the * controlling tty. * All this is made more difficult because we have to handle return values * correctly. If the grep is signaled, its exit status has to be propagated * back to the parent shell which needs it to set the exit status of the * super-job. And of course, when the grep is signaled (including ^C), the * loop has to be stopped, etc. * The code for all this is distributed over three files (exec.c, jobs.c, * and signals.c) and none of them is a simple one. So, all in all, there * may still be bugs, but considering the complexity (with race conditions, * signal handling, and all that), this should probably be expected. */ I hope this explains why bash does what it does.
