|
||||
Table of Contents Previous section 28.3 FROM PROCESSES TO OBJECTSRobin Milner is said to have exclaimed, in a 1991 workshop at an O-O conference, "I can't understand why objects [of O-O languages] are not concurrent in the first place". (Cited in [Matsuoka 1993].) Even if only in the second or third place, how do we go about making objects concurrent? If we start from non-O-O concurrency work, we will find that it largely relies on the notion of process. A process is a program unit that acts like a special-purpose computer: it executes a certain algorithm, usually repeating it until some external event triggers termination. A typical example is the process that manages a printer, repeatedly executing "Wait until there is at least a job in the print queue"; "Get the next print job and remove it from the queue"; "Print the job" Various concurrency models differ in how processes are scheduled and synchronized, compete for shared hardware resources, and exchange information. In some concurrent programming languages, you directly describe a process; in others, such as Ada, you may also describe process types, which at run time are instantiated into processes, much like the classes of object-oriented software are instantiated into objects. SimilaritiesThe correspondence seems indeed clear. As we start exploring how to combine ideas from concurrent programming and object-oriented software construction, it seems natural to identify processes with objects, and process types with classes. Anyone who has studied concurrent computing and discovers object technology, or the other way around, will be struck by the similarities:
So it is not surprising that many people have had a "Eureka!" when first thinking, Milner-like, about making objects concurrent. The unification, it seems, should come easily. This first impression is unfortunately wrong: after the similarities, one soon stumbles into the discrepancies. Active objectsBuilding on the analogies just summarized, a number of proposals for concurrent O-O mechanisms (see the bibliographical notes) have introduced a notion of "active object". An active object is an object that is also a process: it has its own program to execute. In a definition from a book on Java (Doug Lea, "Concurrent Programming in Java", Addison-Wesley, 1996): Each object is a single, identifiable process-like entity (not unlike a Unix process) with state and behavior. This notion, however, raises difficult problems. The most significant one is easy to see. A process has its own agenda: as illustrated by the printer example, it relentlessly executes a certain sequence of actions. Not so with classes and objects. An object does not do one thing; it is a repository of services (the features of the corresponding class), and just waits for the next client to solicit one of those services, as chosen by the client, not the object. If we make the object active, it becomes fully responsible for the scheduling of its operations. This immediately creates a conflict with the clients, which of course have their own view of what the scheduling should be: they simply want their supplier to provide each service when they need it! The problem arises in non-object-oriented approaches to concurrency and has led to mechanisms for synchronizing processes --- that is to say, specifying when and how each is ready to communicate, waiting if necessary for the other to be ready too. For example, in a very simple, unbuffered producer-consumer scheme we may have a producer process that repeatedly executes
a scheme which we may also view pictorially:
Communication occurs when both processes are ready for each other; this is sometimes called a handshake or rendez-vous. The design of synchronization mechanisms --- making it possible in particular to express precisely the instructions to "Make it known that process is ready" and "Wait for process to be ready" --- has been a fertile area of research and development for several decades. All this is fine for processes, the concurrent equivalent of traditional sequential programs which "do one thing"; indeed, a concurrent system built with processes is like a sequential system with several main programs. But in the object-oriented approach we have rejected the notion of main program and instead defined software units that stand ready to provide any one of a number of possible features. Reconciling this view with the notion of process requires elaborate synchronization constructs to make sure that each supplier is ready to execute a feature when the client needs it. This is particularly delicate when both client and supplier are active objects, since each has its own agenda. All this does not make it impossible to devise mechanisms based on the notion of active object, as evidenced by the abundant literature on the subject (to which the bibliographical notes to this chapter gives many references). But this evidence also shows the complexity of the proposed solutions, of which none has gained wide acceptance, suggesting that the active object approach is not the right one. Active objects clash with inheritanceDoubts about the suitability of the active object approach grow as one starts looking at how it combines with other O-O mechanisms, especially inheritance. If a class B inherits from a class A and both are active (that is to say, describe instances that must be active objects), what happens in B to the process description of A? In many cases you will need to add some new instructions, but without special language mechanisms this means that you will almost always have to redefine and rewrite the entire process part --- not an attractive proposition. Here is an example of special language mechanism. Although the Simula 67 language does not support concurrency, it has a notion of active object: a Simula class can, besides its features, include a set of instructions, called the body of the class, so that we can talk of executing an object --- meaning executing the body of its generating class. The body of a class A can include a special instruction inner, which has no effect in the class itself but, in a proper descendant B, stands for the body of B. So if the body of A reads some_initialization; inner; some_termination_actions and the body of B reads specific_B_actions then execution of that body actually means executing some_initialization; specific_B_actions; some_termination_actions Although the need for a mechanism of this kind is clear in a language supporting the notion of active object, objections immediately come to mind: the notation is misleading, since if you just read the body of B you will get a wrong view of what the execution does; and it only works in a single-inheritance language. Even with a different notation, the basic problem will remain: how to combine the process specification of a class with those of its proper descendants; how to reconcile parents' process specifications in the case of multiple inheritance. Later in this chapter we will see other problems, known as the "inheritance anomaly" and arising from the use of inheritance with synchronization constraints. Faced with these difficulties, some of the early O-O concurrency proposals preferred to stay away from inheritance altogether. Although justifiable as a temporary measure to help understand the issues by separating concerns, this exclusion of inheritance cannot be sustained in a definitive approach to the construction of concurrent object-oriented software; this would be like cutting the arm because the finger itches. (For good measure, some of the literature adds that inheritance is a complex and messy notion anyway, as if telling the patient, after the operation, that having an arm was a bad idea in the first place). The inference that we may draw is simpler and less extreme. The problem is not object technology per se, in particular inheritance; it is not concurrency; it is not even the combination of these ideas. What causes trouble is the notion of active object. Processes programmedAs we prepare to get rid of the notion of active object we may note that we will not really be renouncing anything. An object is able to perform many operations: all the features of its generating class. By turning it into a process, we select one of these operations as the only one that really counts. There is absolutely no benefit in doing this! Why limit ourselves to one algorithm when we can have as many as we want? Another way to express this observation is that the notion of process need not be a built-in concept in the concurrency mechanism; processes can be programmed simply as routines. Consider for example the concept of printer process cited at the beginning of this chapter. The object-oriented view tells us to focus on the object type, printer, and to treat the process as just one routine, say live, of the corresponding class: indexing description: "Printers handling one print job at a time"; note: "A better version, based on a general class PROCESS, % %appears below under the name PRINTER" class PRINTER_1 feature -- Status report stop_requested: BOOLEAN is do ... end; oldest: JOB is do ... end; feature -- Basic operations setup is do ... end; wait_for_job is do ... end; remove_oldest is do ... end; print (j: JOB) is do ... end; feature -- Process behavior live is -- Do the printer thing. do from setup until stop_requested loop wait_for_job; print (oldest); remove_oldest end end; ... Other features ... end -- class PRINTER_1 Note the provision for Other features: although so far live and the supporting features have retained all our attention, we can endow processes with many other features if we want to, encouraged by the O-O approach developed elsewhere in this book. Turning PRINTER_1 objects into processes would mean limiting this freedom; that would be a major loss of expressive power, without any visible benefit. By abstracting from this example, which describes a process type simply as a class, we can try to provide a more general description of processes through a deferred class. Procedure live will apply to all processes. We could leave it deferred, but it is not too much of a commitment to note that most processes will need some initialization, some termination, and in-between a basic step repeated some number of times. So we can already effect a few things at the most abstract level: indexing description: "The most general notion of process" deferred class PROCESS feature -- Status report over: BOOLEAN is -- Must execution terminate now? deferred end; feature -- Basic operations setup is -- Prepare to execute process operations (default: nothing). do end; step is -- Execute basic process operations. deferred end; wrapup is -- Execute termination operations (default: nothing). do end feature -- Process behavior live is -- Perform process lifecycle. do from setup until over loop step end; wrapup end end -- class PROCESS A point of methodology: whereas step is deferred, setup and wrapup are effective, being defined as procedures that do nothing. This way, we force every effective descendant to provide a specific implementation of step, the basic process action; but in the not infrequent cases that do not require any particular setup or termination operation we do not bother the descendants. This choice between a deferred version and a null effective version occurs regularly in the design of deferred classes, and you should resolve it based on your appreciation of the likely characteristics of descendants. A wrong guess is not catastrophic; it will just lead to more effectings or more redefinitions in descendants. From this pattern we may define a more specialized class, covering printers: indexing description: "Printers handling one print job at a time"; note: "Revised version based on class PRINTER" class PRINTER inherit PROCESS rename over as stop_requested end feature -- Status report stop_requested: BOOLEAN -- Is the next job in the queue a request to shut down? oldest: JOB is -- The oldest job in the queue do ... end; feature -- Basic operations step is -- Process one job. do wait_for_job; print (oldest); remove_oldest end; wait_for_job is -- Wait until job queue is not empty. do ... ensure oldest /= Void end; remove_oldest is -- Remove oldest job from queue. require oldest /= Void do if oldest.is_stop_request then stop_requested := True end; "Remove oldest from queue" end; print (j: JOB) is -- Print j, unless it is just a stop request. require j /= Void do if not j.is_stop_request then "Print the text associated with j" end end; end -- class PRINTER The class assumes that a request to shut off the printer is sent as a special print job j for which j.is_stop_request is true. (It would be cleaner to avoid making print and remove_oldest aware of the special case of the "stop request" job; this is easy to improve: see exercise 28.1.) The benefits of O-O modeling are apparent here. In the same way that going from main program to classes broadens our perspective by giving us abstract objects that are not limited to "doing just one thing", considering a printer process as an object described by a class opens up the possibility of new, useful features. With a printer we can do more than execute its normal printing operation as covered by live (which we should perhaps have renamed operate when inheriting it from PROCESS); we might want to add such features as perform_internal_test, switch_to_Postscript_level_1 or set_resolution. The equalizing effect of the O-O method is as important here as in sequential software. More generally, the classes sketched in this section show how nicely we can use the normal object-oriented mechanisms --- classes, inheritance, deferred elements, partially implemented patterns --- to implement processes. There is nothing wrong with the concept of process in an O-O context; indeed, we will need it in many concurrent applications. But rather than a primitive mechanism it will simply be covered by a library class PROCESS based on the version given earlier in this section, or perhaps several such classes covering variants of the notion. For the basic new construct of concurrent object technology, we must look elsewhere. Table of Contents Next section
|