• No results found

Chapter 7 Specifying and Checking Effects for Framework APIs

7.2 Safe, Reusable Parallel Frameworks

7.2.2 A List Node Container

We now show how to use the DPJ type system as described in previous chapters (i.e., without extending the effect system yet) to write an abstract disjoint container API that storesNodeobjects and allows safe parallel updates to the stored objects. The disjoint container implementation is not specified; it could be any container (set, list, tree, etc.). The point is that we will be able to write a container API that (1) stores as elements list nodes, which may have cross links between them, as shown in Figure 7.3; and (2) allows update operations on the elements to be done safely in parallel, despite the presence of the cross links. While the list node container is somewhat artificial, we will extend the example to a more generic (and more useful) container in later sections.

Writing the list node container API presents two problems: maintaining disjointness, and reasoning about effects. Our key insight is that through careful API design, together with judicious use of method region parameters, we can enforce restrictions like “a factory method must return a new object” or “an apply method must write only to the region of the object it is given.” Further, we can impose these restrictions without exposing region names (such asFirstandSecondin Figure 7.2), that would otherwise prevent swapping and other disjointness-preserving operations inside the framework.

Maintaining Disjointness: To maintain disjointness, we use the following strategy: (1) every container

starts empty and so is trivially disjoint; and (2) every operation provided by the disjoint container API is disjointness-preserving (takes a disjoint container to another disjoint container). By a simple induction, we can then conclude that the container is disjoint throughout its lifetime. The hard part is guaranteeing property (2). There are two types of operations to consider: (a) operations that are totally under the control of the container implementation and (b) operations that must cooperate with (possibly unknown) user code. An example of (a) is a tree rebalancing or array shuffling that operates only on the internal structure of the container. Here the problem is entirely reduced to writing a correct framework implementation (Section 7.2.4). In the case of (b), however, the framework must restrict what the user can do so that the framework author can reason soundly about uses of the container without knowing exactly what that use

will look like. A core example is putting things into a container. For the container to be useful, the user has to retain control over what is inserted in the container, and how and where those inserted things are created. The trick is to allow some control while still being able to reason about disjointness. We have explored the following two strategies: building one disjoint container from another, and controlled creation of contained objects.

Figure 7.4 shows the simple list node container API that we use to illustrate these strategies. There are two region parameters,RNandRC, because we want to refer separately to the nodes stored in the container, and the container itself. In line 1, we use a region parameter constraint (described in Chapter 3) to require that for any instantiation ofNodeContainerthat bindsR1toRNandR2toRC,R1:*andR2are disjoint.

This ensures that reading the container to traverse the slots does not interfere with updating the contained objects.

Building one disjoint container from another: If we start with a disjoint containerC1, and we create a new

disjoint container C2, we can populate C2 by copying the reference elements from the slots of C1 to the

slots ofC2, andC2will also be disjoint. An example is creating a tree out of the elements of an array or set. 1 public interface NodeContainer<region RN,RC | RN:* # RC> {

2

3 /* One linear container from another */

4 public NodeContainer(NodeContainer<RN,RC> c) writes RC; 5

6 /* Controlled creation of contents */

7 public NodeContainer(NodeFactory fact, int size) writes RC; 8 public interface NodeFactory {

9 public <region R>Node<R> create(int i) pure;

10 }

11

12 /* Data parallel operation on all elements */

13 public void performOnAll(Operation<RN> op) reads RC writes RN:*; 14 public interface Operation {

15 public <region R>void operateOn(Node<R> elt) writes R;

16 }

17 18 }

Figure 7.4: Framework API for an abstract disjoint list node container

Line 4 of Figure 7.4 illustrates how we might implement this strategy in DPJ. It says that given one object of typeNodeContainer<RN,RC>we can create another one. An important special case in DPJ is creating a disjoint container from an index-parameterized array. As described in Chapter 3, the index-

parameterized array type is an arrayAsuch that cellA[i]has a type likeListNode<[i]>that is parame- terized by the integer valuei. This guarantees disjointness for the array, because the region[i]is distinct in the type of each array cell. However, because the parameterized types are exposed to the rest of the program, it also means that we cannot shuffle the array elements without compromising soundness. (This is exactly the same problem discussed in Section 7.1, just with array cells rather than fields.) If we construct a disjoint container by copying in elements from the cells of an index-parameterized array, then we obtain a container that is disjoint, but on which we can also perform disjointness-preserving operations, such as shuffling, that were prohibited for the original array by doing them internally within the framework.

Controlled creation of contained objects: Lines 7–10 of Figure 7.4 illustrate this strategy, for an interface to NodeContainerthat could be implemented in different ways (array, tree, etc). The container implemen- tation does the actual object creation, but the user specifies the number of objects to create and provides a factory method specifying how to create theith object. For example, a use could look like this, assuming a classNodeArraythat implementsNodeContainer:

/* Implement concrete create method */

public class MyFactory implements NodeContainer.NodeFactory { public <region R>Node<R> create(int i) {

return new Node<R>(i, null); }

}

/* Declare new region names NodeRegion and ContainerRegion */

region NodeRegion, ContainerRegion;

/* Bind the declared names to the parameters in the type */

NodeContainer<NodeRegion,ContainerRegion> c =

new NodeArray<NodeRegion,ContainerRegion>(new MyFactory(), 10);

This code creates a newNodeArraywith 10 list nodes, such that theith one has itsdatafield set toi. NodeRegionand ContainerRegionare region names declared by the user and bound to the region arguments in the instantiated types.

The important thing here is that the “factory method” must really be a factory method and not, for ex- ample, just fetch some object reference from the heap and store the same one into each slot of the container. The framework author can enforce this requirement by judicious use of a method region parameter. Notice that in line 10, the return type of the factory method is written in terms of a parameterRthat is in scope only in that method. Further, no reference assignable to typeNode<R>enters the method. Therefore, the only way aNode<R>can escape the method is if it is created inside the method vianew.

This strategy gives the framework control over disjointness by hiding the actual regions in the types of the created objects; the user only ever deals with them through the method region parameter in the factory method. For example, an array framework instantiated withRN = Ncould give the Nodeobject stored in sloti the type Node<N:[i]>, whereN:[i]is the index-parameterized RPL discussed above. Unlike the case of the index-parameterized array, however, that type would never be seen by the user, unless the framework allowed it. The framework might simply not provide any way to ask for a reference to the element in sloti. Or, it might give out such a reference with typeNode<N:*>, saying that the exact region in the type is statically unknown. This is sufficient because, in most cases, the user code does not need to distinguish these types since the parallelism is encapsulated inside the framework. The framework could give out a reference with typeNode<N:[i]>if it could soundly match references to their original indices, e.g., if no shuffling of references happened inside the framework.

Reasoning about Effects: Lines 17–22 of Figure 7.4 show the part of the API that allows the user to define

a method and then pass that method into the container to be applied in parallel to all contained objects. For example, given referencecof typeNodeContainer<N,C>, the user could do this:

public class MyOperation implements NodeContainer.Operation { public <region R>void operateOn(ListNode<R> elt) writes R {

++elt.data; }

}

c.performOnAll(new MyOperation());

This code increments thedatafield of each of the objects stored incin parallel.

Effect ofoperateOn: In the definition of the abstractoperateOnmethod in theOperationinterface (lines 20–21 of Figure 7.4), we again use a method region parameter R. We write the type of the formal parametereltasNode<R>, and we specify the effect aswrites R. This causes two things to happen. First, the DPJ type system requires that any user-supplied method implementingoperateOnmust have a declared effect that is a subeffect of writes R. For example, reads R is allowed, but reading or writing some other region is not. (The relevant rules for subeffects are given formally in the next chapter; see also Chapter 4.) Second, because the regions in the objects of the slots are disjoint, the actual regions bound to Rat runtime will be disjoint as the framework traverses the slots and applies the user-supplied method. Together, these two facts guarantee that the effects of the iterations in the parallel traversal will be noninterfering.

As an example, consider the user code shown above for updating aNode. That code is legal, because datais declaredin RinsideNode, which becomesin R(because of the type ofelt) in the scope of operateOn. However, following thenextfield to updatedataof a different object is not legal: because thenextfield has typeListNode<*>, the effect of that update iswrites *, which is not a subeffect ofwrites Rand so is not allowed. So the API prevents the problem noted in Figure 7.3 of causing a race by following cross links. The cross links themselves are allowed, but problematic traversals of them are not. Effect ofperformOnAll: In Figure 7.4, we have written the effect ofperformOnAllas

reads RC writes RN:*.

This is correct if, for a particular implementation of the interface, (1) the slots have typeNode<RN:*>; and (2) the implementation of performOnAllreads the container and applies the user’s operateOn method to the references in the slots. The framework writer is responsible for ensuring that both facts are true. In fact, if the framework itself is written in DPJ, then both facts are checked by the DPJ compiler. We will have more to say in Section 7.2.4 about implementing the framework. For now, note that the effect of operateOnis the only effect on the regions of the nodes themselves; and because that effect is partially specified (RN:*), the framework has freedom to implement the slot regions in different ways.