The first article in this JavaSpaces thread presented an overview of the JavaSpaces programming model and its simple API. You'll recall that the model is based on spaces -- shared, network-accessible object storage and exchange areas -- through which processes communicate and synchronize their activities. Indeed, coordination is a crucial component of distributed programs, just as it is in any distributed activity.
Consider cars speeding through an intersection, workers assembling a car, or athletes playing a basketball game: each of those real-world distributed systems requires coordination to function smoothly. So with distributed applications, the processes need to synchronize with one another to succeed at a common task. For example, distributed applications often need to mediate access to a limited set of shared resources, guarantee fair access to those resources, or prevent processes from terminating as they wait for resources that may never become available.
This month I'll show you how to use JavaSpaces to coordinate processes in a networked environment. At the same time, I'll start tying JavaSpaces and Jini closer together. If you've experimented with JavaSpaces, you already know that it is implemented on top of Jini. In fact, a new article, "The Nuts and Bolts of Compiling and Running JavaSpaces Programs" (see Resources), can help you get your JavaSpaces programs up and running, which can be a frustrating task, given all the Jini machinery needed underneath. What may not be as clear is that JavaSpaces is a powerful tool for communication and coordination between a Jini federation's entities. In particular, space-based communication and coordination works well in the Jini networked environment, where entities may rapidly appear and disappear at will (or against their will, in the case of machine, software, or network failures).
In this article, I'll investigate how you can achieve some simple forms of distributed synchronization by using a space. To illustrate, I'll build a distributed multiplayer game, provided as a Jini service, that permits access by a limited number of players at a time. You'll see how to mediate players' access to the game through a space. Before diving into the game service, let's start by taking a quick look at an important concept that you'll use to build synchronization into your distributed programs: how the JavaSpaces API provides basic synchronization on entries for free.
Coordinating distributed processes can be hard work. In an application that runs on a single machine, the operating system, acting as a centralized manager, can synchronize multiple threads. But in a networked environment, a single point of control doesn't necessarily exist. Processes run on different machines and at their own pace. Unless you want to build a centralized controller to manage those processes, which would introduce an unwelcome bottleneck to the environment, you must build a distributed means of managing their interactions, which can be significantly trickier.
JavaSpaces can ease the synchronization of distributed processes because synchronization is built into the space operations themselves. Recall the basics of the JavaSpaces API: write
deposits an object, called an entry, into a space; read
makes a copy of an entry in a space (but leaves the object there); and take
removes an entry from a space. Let's see how this simple API incorporates the basics of space-based synchronization. Suppose you define a simple Message
entry like this:
public class Message implements Entry {
public String content;
public Message() {
}
}
Then you instantiate a Message
entry and set its content:
Message msg = new Message();
msg.content = "Hello!";
Assuming you have access to a space object, you call its write
method to place a copy of the entry into the space:
space.write(msg, null, Lease.FOREVER);
Now any process with access to the space can create a template and read the entry:
Message template = new Message();
Message result = (Message)space.read(template, null, Long.MAX_VALUE);
Any number of processes can read the entry in the space at a given time. But suppose a process wants to update the entry: the process must first remove the entry from the space, make the changes, and then write the modified entry back to the space:
Message result = (Message)space.take(template, null, Long.MAX_VALUE);
result.content = "Goodbye!";
space.write(result, null, Lease.FOREVER);
It's important to note that a process needs to obtain exclusive access to an entry that resides in the space before modifying it. If multiple processes are trying to update the same entry using this code, only one obtains the entry at a time. The others wait during the take
operation until the entry has been written back to the space, and then one of the waiting processes takes the entry from the space while the others continue to wait.
The basics of space-based programming synchronization can be quickly summarized: Multiple processes can read an entry in a space at any time. But when a process wants to update an entry, it first has to remove it from the space and thereby gain sole access to it. In other words, the read
, take
, and write
operations enforce coordinated access to entries. Entries and their operations give you everything you need to build more complex coordination schemes. With the basics under your belt, let's start building the game service.
Consider a distributed multiplayer game, offered as a Jini service, that allows only a set number of players at a time. If the game is already at full capacity, a person wanting to play has to wait until an active player leaves. In its simplest form, given a pool of waiting players, the service admits an arbitrary player to the game; this is the approach I take in the example. But of course, in this scheme some players may wait forever while relative newcomers are allowed to play. If you want to be fair, you could make the waiting players queue up and have your service mediate admission to the game on a first-come, first-served basis. For now let's implement the simpler arbitrary-player approach, using a small amount of JavaSpaces code.
Here is the overview of my game service, depicted in Figure 1: The Jini game service supplies two basic methods, joinGame
and leaveGame
. When a player wishes to play, he or she looks up a game service provider in the standard Jini way, using a lookup server, and is delivered a proxy object to a game service. (I'm not going to cover the basics of Jini services, lookup, and discovery in this article, so if those concepts and terminology are unfamiliar to you, you may want to make a detour to Resources.) With the proxy in hand, the player calls joinGame
to join a specific game. When joinGame
succeeds in getting admission to the game (via JavaSpaces, as you'll see shortly), it returns an interface to a remote game object, which can be used to play the game. At this point, the player calls the play
method of the game
object (the game's only method in my simple example) to begin playing. When the play
method returns, the player is finished playing and calls the proxy's leaveGame
method. In my example, the player sleeps for a bit and then loops, calling joinGame
to continue playing the game.
Figure 1. An overview of the game service example |
From the command line, you can start a game service, specifying a name for the game and a maximum number of players. Then you can start as many players for that game as you want: if there are fewer players than the maximum, everyone gets to play at once, but if there are more players than the maximum, your game service will need to coordinate access to the game. You may want to download the full example code (Resources) to play with and examine. Let's take a closer look at how the pieces of the system are implemented and how to use JavaSpaces to coordinate them.
Let's start by looking at the implementation of a player. Here is the basic skeleton of the player code, excluding some of the Jini lookup and discovery details (you can find the full code in Player.java
from the code.zip
file in Resources):
public class Player implements Runnable {
protected String gameName;
protected String myName;
...
public Player(String gameName, String myName) throws IOException {
this.gameName = gameName;
this.myName = myName;
...
// create a template for locating a GameServiceInterface, set a
// security manager, & set up a listener for discovery events
// in the Jini public group
...
}
// This method is called whenever a new lookup service is found
protected void lookForService(ServiceRegistrar lookupService) {
...
// use a template to search for proxies that implement GameServiceInterface
...
GameServiceInterface gameInterface = (GameServiceInterface)proxy;
while (true) {
Game game = gameInterface.joinGame(gameName);
if (game != null) {
try {
System.out.println("Playing game " + gameName);
game.play(myName);
} catch (RemoteException e) {
e.printStackTrace();
}
gameInterface.leaveGame();
} else {
System.out.println("Couldn't obtain game " + gameName);
}
// sleep for 10 seconds before trying to join the game again
try {
Thread.sleep(10000);
} catch (Exception e) {
e.printStackTrace();
}
}
}
// Create a Player and start its thread
public static void main(String args[]) {
if (args.length < 2) {
System.out.println("Usage: Player gameName playerName");
System.exit(1);
}
try {
Player player = new Player(args[0], args[1]);
new Thread(player).start(); // start a thread that sleeps
} catch (IOException e) {
System.out.println("Couldn't create player: " + e.getMessage());
}
}
}
As you can see in the player's main
method, you instantiate a new Player
object, passing it the command-line arguments that hold the name of the game that the player wishes to join and the player's name, which the constructor then assigns to member variables. The player constructor also takes care of Jini details, such as creating a template for locating a GameServiceInterface
, setting a security manager, and registering a listener to listen for Jini discovery events.
After the constructor returns, you start a thread that will sleep indefinitely with the help of a loop. This might seem a bit peculiar, but if you don't start the thread, the constructor will finish and the player will simply exit. The thread keeps the player alive and listening for Jini discovery events, which is what you want.
Whenever a lookup service is found, the registered listener (an inner class of player that I am not showing here) is contacted with the event and, in turn, calls the lookForService
method that you see above. Since the player wants to find a game service that provides immediate access to a game, this method uses a template to search for proxies registered with the lookup service that implement the GameServiceInterface
.
Once a proxy has been located, the player enters a loop. First, the player calls the proxy's joinGame
method -- waiting as long as necessary to obtain admission to the game with the given name. Once the player has been admitted, the method returns a remote object that gets assigned to the game. Assuming that the game isn't null
, the player calls the remote object's play
method to start playing the game. When finished playing, the player calls the game proxy's leaveGame
method, which essentially relinquishes the player's slot in the game, making it available to other potential players. The player then sleeps for 10 seconds (apparently, playing games is tiring!) before beginning the loop anew and trying to join the game again.
All the Jini responsibilities of a game service, including finding Jini lookup services and publishing the proxy used to connect to a game, are handled by the GameService
class. Without some of the nitty-gritty Jini lookup and discovery code (which you can peruse in GameService.java
), here's what the game service code looks like:
public class GameService implements Runnable {
protected JavaSpace space;
protected Game game;
...
// method that adds maxPlayers tickets to the named game to the space
private void createTickets(String name, int maxPlayers) {
// instantiate a game with the specified name
try {
game = new GameImpl(name);
} catch (RemoteException e) {
e.printStackTrace();
}
// write maxPlayers game tickets to the space
for (int i = 0; i < maxPlayers; i++) {
Ticket ticket = new Ticket(name, game);
try {
space.write(ticket, null, Lease.FOREVER);
} catch (Exception e) {
e.printStackTrace();
}
}
}
// method that creates a proxy object that implements the GameServiceInterface
protected GameServiceInterface createProxy() {
GameServiceInterface gameServiceProxy = new GameServiceProxy();
gameServiceProxy.setSpace(space);
return gameServiceProxy;
}
public GameService(String gameName, int numTickets) throws IOException {
space = SpaceAccessor.getSpace();
item = new ServiceItem(null, createProxy(), null);
// ...
// set a security manager, & set up a listener for discovery events
// ...
// create the specified number of tickets to the named game
createTickets(gameName, numTickets);
}
public static void main(String args[]) {
if (args.length < 2) {
System.out.println("Usage: GameService gameName numTickets");
System.exit(1);
}
try {
String gameName = args[0];
int numPlayers = Integer.parseInt(args[1]);
GameService gameService = new GameService(gameName, numPlayers);
new Thread(gameService).start();
} catch (Exception e) {
System.out.println("Could not create game service: " + e.getMessage());
e.printStackTrace();
}
}
}
Let's look at the pieces, starting with the main
method. You'll see that it instantiates a new GameService
object -- passing it command-line arguments that specify the name of this particular game and the maximum number of players the game should admit. Then, just as you saw in the player code, main
starts up a thread (which simply loops indefinitely) to keep this object from terminating. When you construct a GameService
object, several interesting things happen. First, you obtain a reference to a JavaSpace (how SpaceAccessor
accomplishes this will be detailed in a future article) and assign it to the space member variable. This is the space that the game service and players use to coordinate their access to the game.
Next you create an instance of ServiceItem
, the object that the game service registers with any lookup services it finds. You'll notice the call to createProxy
, which instantiates a GameServiceProxy
, stashes a reference to the space within the proxy, and returns the proxy. This proxy gets embedded in the service item and becomes the means that players use to join and leave the game (as you saw in the player).
Then the constructor takes care of some Jini details. It's important to understand that whenever the game service hears about Jini discovery events, it registers your service item (with the proxy to the game service) with any new lookup services; in other words, it "publishes" the game proxy whenever it gets a chance. Finally, the constructor calls the createTickets
method, which sets the stage for how you'll coordinate access to the game.
The basic idea behind mediating access to the game is that the game service admits only those players holding a ticket (just as in the real world). Before looking in detail at createTickets
, you should know what a Ticket
entry looks like:
import net.jini.core.entry.Entry;
public class Ticket implements Entry {
public String gameName;
public Game game;
public Ticket() {
}
public Ticket(String gameName) {
this.gameName = gameName;
}
public Ticket(String gameName, Game game) {
this.gameName = gameName;
this.game = game;
}
}
When you have a ticket for an event in hand, it should include information about and directions to the event. So here are the two pieces of information on every ticket: the name of the game and a reference to a remote Game
object that a player can use to access that game. Ticket
also has a no-arg constructor (which JavaSpaces entries require) and two other constructors provided for convenience.
Now returning to createTickets
, you'll notice that it first instantiates a remote game object and assigns the remote reference to the game. With a reference to the remote game in hand, createTickets
enters a loop to create tickets. Given that maxPlayers
specifies the maximum number of players the game should permit at a time, createTickets
instantiates exactly that many Ticket
objects for this game. It passes each Ticket
the name of the game and the reference to the remote game, and writes the Ticket
s into the space.
Those are the basics of the game service. Essentially, it publishes a game service proxy that players use to join and leave the game, and it initializes the JavaSpace you're using with an appropriate number of tickets to mediate access to the game.
In my example, the game itself isn't very interesting. But you're probably curious about how the game is implemented and what it does, so let's take a quick peek. Here is the code for the remote game interface (defined in Game.java
):
import java.rmi.*;
public interface Game extends Remote {
public void play(String playerName) throws RemoteException;
}
Here is the code for the GameImpl
implementation of that interface (defined in GameImpl.java
):
import java.rmi.*;
import java.rmi.server.*;
public class GameImpl extends UnicastRemoteObject implements Game {
private String name;
public GameImpl(String name) throws RemoteException {
super();
this.name = name;
// Create and install a security manager
if (System.getSecurityManager() == null) {
System.setSecurityManager(new RMISecurityManager());
}
}
// play just prints out messages & sleeps
public void play(String playerName) {
for (int i = 0; i < 5; i++) {
System.out.println(playerName + " is playing...");
try {
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
If you're not familiar with the basics of RMI and remote objects, you may find Resources helpful. This is an extremely minimal game: the game implementation only supplies a play
method, which simply loops, printing messages and sleeping for a bit each time. Of course, there are more elaborate games that supply additional methods and do very interesting things in play
. In particular, it would make sense for the play
method of such a distributed multiplayer game to communicate information to other game players, and a JavaSpace would be a natural way to accomplish this.
In this example, though, your focus isn't on playing an interesting game but on how to coordinate players' access to it. With this in mind, let's turn to the picture's one missing piece: the implementation of the game service proxy that players use to join and leave the game.
The game service interface (defined in GameServiceInterface.java
) looks like this:
public interface GameServiceInterface {
public void setSpace(JavaSpace space);
public Game joinGame(String name);
public void leaveGame();
}
The GameServiceProxy
class (defined in GameService.java
) provides an implementation of this interface as shown here:
class GameServiceProxy implements Serializable, GameServiceInterface {
private JavaSpace space;
private String name;
private Game game;
public GameServiceProxy() {
}
public void setSpace(JavaSpace space) {
this.space = space;
}
// To join the game, obtain a ticket to the game from the space
public Game joinGame(String name) {
this.name = name;
System.out.println("Trying to get ticket to " + name);
Ticket ticketTemplate = new Ticket(name);
Ticket ticket;
try {
ticket = (Ticket)space.take(ticketTemplate, null, Long.MAX_VALUE);
this.game = ticket.game;
System.out.println("Got a ticket to " + name);
} catch (Exception e) {
e.printStackTrace();
}
return game;
}
// When leaving the game, write a ticket to the game back to the space
public void leaveGame() {
if (game == null) {
System.out.println("Not participating in a game!");
} else {
Ticket ticketEntry = new Ticket(name, game);
try {
space.write(ticketEntry, null, Lease.FOREVER);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("Left the game " + name);
name = null;
game = null;
}
}
}
Earlier you saw how the game service instantiates a game service proxy and then calls its setSpace
method to stash a remote reference to the JavaSpace in which the game service stores tickets. You've also seen how players -- once they've used Jini lookup to find the game service proxy -- enter a loop in which they repeatedly join the game, play for a while, and leave the game.
It's time I show you how joining and leaving are implemented. The joinGame
method takes the name of the game the player wishes to join as an argument, and stashes the name in the member variable name for future reference. Then it creates an entry template from Ticket
that contains the name of this particular game. With a template in hand, you call take
on the space to retrieve a ticket entry. This is where the built-in synchronization of space operations comes into play: if the game is already full to capacity, then there are currently no tickets for this game in the space and take
waits as long as necessary for a ticket to arrive. There could be many players waiting in joinGame
's take
method. Once you've retrieved a ticket to the game, you pick up the reference to the remote game that the game service stashed in the ticket and you assign it to the member variable game
. The reference is also returned by the joinGame
method.
At this point, the pool of tickets available in the space has been reduced by one, and the player who called joinGame
holds a ticket and is free to play the game. In fact, you'll recall that the player calls play
on the game object returned by joinGame
. When finished playing, the player calls leaveGame
. Let's take a look at what this method does. Note that you don't need to pass a game name to leaveGame
, since the proxy has already stored the name in its variable name
.
First, the method checks the variable game
to make sure that the player is already in a game (you can't leave a game if you're not playing one!). If that test is passed, you create a new Ticket
entry, filling in its fields with the name and remote reference for the game currently being played (in other words, the one the player holds a ticket for). Then you write that ticket entry into the space and reset the game
and name
member variables to null
to reflect that you've left the game.
Let's consider what happens when the player writes a Ticket
to the game back into the space. Figure 2 may help you visualize what's happening. If no other players are waiting to play the game, the ticket just remains in the space. If on the other hand, many players are waiting (in other words, many players are blocked on joinGame
's take
method), then one will be lucky enough to snatch up the replaced ticket, complete its call to joinGame
, and proceed to play. In this example, you have no control over who will get the ticket, but you could certainly devise a more sophisticated first-come, first-served scheme that requires players to queue up when they want tickets.
There you have it: with just a few JavaSpaces operations, you've managed to coordinate any number of players that want access to your game.
Figure 2. Players coordinating their access to the game through a JavaSpace |
It's time to take the example out for a spin. Once you have all the Jini services up and running (see Resources if you need help), you start the game service with a command similar to this (on the Windows platform):
java -Djava.security.policy=C:\jini1_0_1\example\books\policy.all
-cp .;C:\jini1_0_1\lib\jini-core.jar;C:\jini1_0_1\lib\jini-ext.jar;
C:\jini1_0_1\lib\sun-util.jar;C:\jini1_0_1\lib\space-examples.jar;
javaworld.gameservice.GameService "Fun Game" 2
Here, the two command-line arguments specify that the service should create a game ("Fun Game") that permits a maximum of two players at any time. Then you can start up as many clients as you want, using commands similar to these:
java -Djava.security.policy=C:\jini1_0_1\example\books\policy.all
-cp .;C:\jini1_0_1\lib\jini-core.jar;C:\jini1_0_1\lib\jini-ext.jar;
C:\jini1_0_1\lib\sun-util.jar;
javaworld.gameservice.Player "Fun Game" duke
java -Djava.security.policy=C:\jini1_0_1\example\books\policy.all
-cp .;C:\jini1_0_1\lib\jini-core.jar;C:\jini1_0_1\lib\jini-ext.jar;
C:\jini1_0_1\lib\sun-util.jar;
javaworld.gameservice.Player "Fun Game" mickey
java -Djava.security.policy=C:\jini1_0_1\example\books\policy.all
-cp .;C:\jini1_0_1\lib\jini-core.jar;C:\jini1_0_1\lib\jini-ext.jar;
C:\jini1_0_1\lib\sun-util.jar;
javaworld.gameservice.Player "Fun Game" minnie
With these commands, you've started three players who want to play Fun Game -- "duke," "mickey," and "minnie." However, only two of them can play at any time. You'll see output in each player window similar to the following:
Got a matching service.
Trying to get ticket to Fun Game
Got a ticket to Fun Game
Playing game Fun Game
Left the game Fun Game
Trying to get ticket to Fun Game
Got a ticket to Fun Game
Playing game Fun Game
Left the game Fun Game
Trying to get ticket to Fun Game
...
In the window for the game service, you'll see output similar to this:
found JavaSpaces = com.sun.jini.outrigger.SpaceProxy@1
Discovered new lookup service
serviceID initialized to 123907da-3cde-483c-8f7b-bb46e9fbd8ae
...
minnie is playing...
duke is playing...
minnie is playing...
duke is playing...
minnie is playing...
duke is playing...
minnie is playing...
duke is playing...
mickey is playing...
duke is playing...
mickey is playing...
mickey is playing...
mickey is playing...
mickey is playing...
minnie is playing...
minnie is playing...
duke is playing...
...
If you pay close attention to the players' windows, you'll see that only two players are playing at any time, while the third waits for a ticket. When a player is finished, relinquishes the ticket, and goes to sleep for a bit, the waiting player gets a chance to grab the newly available ticket and start playing.
In this example, I've demonstrated a simple Jini service that coordinates access to a limited resource (a game with limited capacity). With a small amount of JavaSpaces code, I've managed to coordinate the activities of any number of players. I've already mentioned that the algorithm doesn't guarantee fair access to the game, but with some work, you could implement a turn-taking scheme to coordinate the players. My example also does nothing to guarantee that tickets won't be lost if, for instance, a ticket-holding player crashes while playing the game. You can remedy this situation by using transactions, which I'll explain in a future article.
I also want to point out that this example is flexible enough to mediate access to any number of games. The single limitation is that you can specify only one game name on the command line to the game service, so it creates tickets for just one game. This, however, is easy to fix. In fact, you could move the createTickets
to a separate "Ticket Generator" program and run it whenever you want to generate a set of tickets for a new game.
JavaSpaces is a natural way to synchronize processes in a distributed environment that has no central controller. But what about the example in this article? Here, the Jini game service could certainly serve as a central point of control -- via some kind of internal data structure -- for admitting players to a game. The JavaSpaces approach has an advantage because it scales naturally: it works just as well if thousands of players try to access a game. If the Jini service were to mediate access, a bottleneck could occur at the internal data structure that handles the access, since players need to access that data structure sequentially. In the case of a space -- which is designed for concurrent access -- the players simply wait around for tickets, without a centralized data structure handling access to the game.
Another advantage of using JavaSpaces to coordinate your Jini applications is that it allows easy interaction with other Jini services. In the example game, for instance, the tickets are kept in a space and are available to other services that may need to interact with them. Suppose you revamp your remote game with a much improved play
method. You can simply write a program that removes tickets from the JavaSpace in which they're stored, modifies the game field to refer to your new remote game object, and returns the tickets to the space. Without modifying or recompiling the game service, players automatically connect to your improved game the next time they gain access.
Suppose you want to broaden the access to your game. You could simply write a program that writes additional tickets into the JavaSpace. Again, you wouldn't have to touch any other code in the system or bring the game service down, since the concept of "maximum players" is embodied only in the number of tickets that reside in the space. Another advantage of using JavaSpaces is that it permits tickets to be "leased" in the space (leasing is another concept that I'll cover in future articles). Instead of writing tickets into the space and asking that they be stored indefinitely, you can give them a time limit, after which the space will remove them. This can be convenient if you want a game to be played within a certain time frame. You simply specify that tickets should disappear when the time is up, and players won't be able to obtain access to the game after that time.
By now you should have a sense that space-based communication and coordination is a highly flexible and natural match to the plug-and-play nature of the Jini networked environment, where entities come and go. This point will be reinforced as Eric and I cover leasing and transactions later in the series.
"Make Room for JavaSpaces, Part III" by Susan Hupfer was originally published by JavaWorld (www.javaworld.com), copyright IDG, March 2000. Reprinted with permission.
http://www.javaworld.com/jw-03-2000/jw-03-jiniology.html
Have an opinion? Be the first to post a comment about this article.
Dr. Susanne Hupfer is director of product development for Mirror Worlds Technologies, a Java- and Jini-based software applications company, and a research affiliate in the Department of Computer Science at Yale University, where she completed her PhD in space-based systems and distributed computing. Previously, she taught Java network programming as an assistant professor of computer science at Trinity College. Susanne coauthored the Sun Microsystems book JavaSpaces Principles, Patterns, and Practice.
Artima provides consulting and training services to help you make the most of Scala, reactive
and functional programming, enterprise systems, big data, and testing.