例子1(转账:想清楚这个数据由哪个线程维护,并负责到底)
Constant.java
package org.example.account.constant;
public class Constant {
/**
* 有多少个账户
*/
public static int ACCOUNT_NUM = 100;
/**
* 每个人初始化的金币
*/
public static int MONEY = 100;
/**
* 发起转账数量
*/
public static int TRANSFER_NUM = 10000000;
}
Account.java
package org.example.account.model;
public class Account {
/**
* 玩家标识
*/
private int id;
/**
* 玩家当前拥有的金钱数
*/
private int money;
public Account(int money, int id) {
this.money = money;
this.id = id;
}
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
public int getId() {
return id;
}
@Override
public String toString() {
return "Account{" +
"id=" + id +
", money=" + money +
'}';
}
}
AccountService.java
package org.example.account.service;
import org.example.account.model.Account;
import java.util.concurrent.CountDownLatch;
public class AccountService {
public static void transferSafe(Account a, Account b, int num, CountDownLatch c) {
// 模拟Netty收到a账户向b账户发起转账
TaskExecuteService.getEsAtIndex(a.getId()).submit(() -> {
if (a.getMoney() >= num) {
a.setMoney(a.getMoney() - num);
// 对b的修改确保在b的线程
TaskExecuteService.getEsAtIndex(b.getId()).submit(() -> {
b.setMoney(b.getMoney() + num);
c.countDown();
});
} else {
c.countDown();
}
});
}
public static void transferUnSafe(Account a, Account b, int num, CountDownLatch c) {
// 模拟a账户发起转账
TaskExecuteService.getEsAtIndex(a.getId()).submit(() -> {
if (a.getMoney() >= num) {
a.setMoney(a.getMoney() - num);
// 修改b的数据也在a所在的线程,这让多个线程都会对b的数据进行修改,是线程不安全的
b.setMoney(b.getMoney() + num);
c.countDown();
} else {
c.countDown();
}
});
}
}
TaskExecuteService.java
package org.example.account.service;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TaskExecuteService {
private static ExecutorService[] esArray = new ExecutorService[8];
public static void init() {
for (int i = 0; i < esArray.length; i++) {
esArray[i] = Executors.newSingleThreadExecutor();
}
}
/**
* Netty业务线程池
*
* @param index
* @return
*/
public static ExecutorService getEsAtIndex(int index) {
return esArray[index % esArray.length];
}
}
AccountTest.java
package org.example.account;
import org.example.account.constant.Constant;
import org.example.account.model.Account;
import org.example.account.service.AccountService;
import org.example.account.service.TaskExecuteService;
import org.junit.Test;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadLocalRandom;
public class TestAccount {
@Test
public void test1() throws InterruptedException, IOException {
// Netty业务线程初始化
TaskExecuteService.init();
// 初始化账户
Account[] accountArray = new Account[Constant.ACCOUNT_NUM];
for (int i = 0; i < accountArray.length; i++) {
accountArray[i] = new Account(Constant.MONEY, i);
}
// 用于等待所有的任务执行结束
CountDownLatch c = new CountDownLatch(Constant.TRANSFER_NUM);
// 模拟相互转账业务
for (int i = 0; i < Constant.TRANSFER_NUM; i++) {
// 转账金额
int num = ThreadLocalRandom.current().nextInt(1, 10);
int index1 = ThreadLocalRandom.current().nextInt(0, accountArray.length);
int index2 = ThreadLocalRandom.current().nextInt(0, accountArray.length);
Account a1 = accountArray[index1];
Account a2 = accountArray[index2];
// AccountService.transferUnSafe(a1, a2, num, c); // 实际:5352 期望:10000
AccountService.transferSafe(a2, a1, num, c); // 实际:10000 期望:10000
}
// 等待任务执行结束
c.await();
// 验证下数据正确性
int totalMoney = 0;
for (int i = 0; i < accountArray.length; i++) {
totalMoney += accountArray[i].getMoney();
}
System.out.println("实际:" + totalMoney + " 期望:" + Constant.ACCOUNT_NUM * Constant.MONEY);
}
}
总结1:想清楚这个数据是 哪个线程维护的,让对这个数据的操作始终由这个线程执行,避免多个线程篡改。
可见,对一个玩家的数据操作只发生在一个线程,那么,是可以保证线程安全的。
其实想想,这种队列术,其实和actor的思想是一样的,确保同一时刻,只有一个线程对玩家数据会发生修改。 只不过skynet中的actor对cpu的利用率更高,因为:这个线程,是任何一个空闲的线程。 而我们这个则进行了线程的绑定,有可能部分cpu在睡大觉。
但是整体来说,已经是足够了。以为在面对较多的用户时,基本上每个cpu都在执行任务,不会有太多的空闲。
总结2:再次思考线程安全的问题:想清楚这个数据是 哪个线程维护的,让对这个数据的操作始终由这个线程执行,避免多个线程篡改。
再想想什么时候会出现并发安全的问题? 那一定是:多个线程对同一个实例变量写时,才会出现并发安全。那么其它线程可以读,但是修改数据的权利只会让维护数据的这个线程具有修改的权利,那么不就可以消除线程安全的问题了。
其实这句话并不完全正确,这句话我认为是保证了数据一致性是没问题,但是可能出现: 线程1读这个数据A,接着线程2修改数据A,但是线程1显示的其实还是oldA,有可能显示时是有问题,但是数据其实是对的。其实吧,UI上的显示,本来就是:可能是不准确的,这其实没有什么,下次刷新下UI就可以了,毕竟逻辑数据是对的。
总结3:一定要想清楚数据是哪个线程维护的。
再次思考一个案例:玩家0借用玩家1,2,3,4,5英雄的问题。玩家1~5肯定是各自维护自己英雄是否已经被借的数据信息 。 玩家0则维护了自己所借用的5个英雄,及其每个英雄的剩余的时间戳。
那么是否被借的标记是玩家1~5所维护。那么修改是否被借的信息,那么现在就想清楚了每个数据的修改应该在哪个线程处理的问题。
思考4:c#中有await可以实现单线程异步
由于ET中是单进程单线程,因此出现了IO的话,为了不阻塞这个业务线程,必须要让别的线程阻塞,但是C#语法糖是甜的,可以单线程实现异步,其实就类似于全局while,过一段时间询问下:干完没,干完了,就接着执行,但是写起来,则是很丝滑。但是既然是:网络IO,实际的业务逻辑,可能是在别的机器进程上执行的,c#的单线程异步,等于是:没有显式的回调。
例子2.通过rpc的形式要数据实现线程间通讯,从而避免因为中间状态导致的数据错误。
对共享资源进行只读,也可能读到脏数据,因为可能一个线程操作到一半,另外一个线程读取,这时是脏数据。
Resource.java
package org.example.testThread;
public class Resource {
private String name;
private String sex;
public Resource(String name, String sex) {
this.name = name;
this.sex = sex;
}
@Override
public String toString() {
return "Resource{" +
"name='" + name + '\'' +
", sex='" + sex + '\'' +
'}' + Thread.currentThread().getName();
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSex() {
return sex;
}
public void setSex(String sex) {
this.sex = sex;
}
}
线程不安全的做法:
Input.java
package org.example.testThread.method1;
import org.example.testThread.Resource;
public class Input implements Runnable {
private Resource r;
public Input(Resource r) {
this.r = r;
}
@Override
public void run() {
int x = 0;
while (true) {
if (x == 0) {
r.setName("mike");
r.setSex("男");
} else {
r.setName("丽丽");
r.setSex("女");
}
x = (x + 1) % 2;
}
}
}
Output.java
package org.example.testThread.method1;
import org.example.testThread.Resource;
public class Output implements Runnable {
private Resource r;
public Output(Resource r) {
this.r = r;
}
@Override
public void run() {
while (true) {
System.out.println(r);
}
}
}
Main1.java
package org.example.testThread.method1;
import org.example.testThread.Resource;
public class Main1 {
public static void main(String[] args) {
Resource r = new Resource("mike", "男");
Input in = new Input(r);
Output out = new Output(r);
new Thread(in).start();
new Thread(out).start();
}
}
/*
Resource{name='丽丽', sex='女'}Thread-1
Resource{name='mike', sex='男'}Thread-1
Resource{name='丽丽', sex='男'}Thread-1 // error
Resource{name='mike', sex='女'}Thread-1 // error
*/
线程安全的做法(通过rpc的方式,一个线程往另外一个线程要数据,从而避免中间状态)
Main2.java
package org.example.testThread.method2;
import org.example.testThread.Resource;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Main2 {
public static void main(String[] args) {
ExecutorService input = Executors.newSingleThreadExecutor();
ExecutorService output = Executors.newSingleThreadExecutor();
input.submit(() -> {
Resource r = new Resource("mike", "男");
int x = 0;
while (true) {
if (x == 0) {
r.setName("mike");
r.setSex("男");
} else {
r.setName("丽丽");
r.setSex("女");
}
x = (x + 1) % 2;
// 模拟一个output线程发出请求到input线程拿到数据后,再次回到input线程
output.submit(() -> {
System.out.println(r);
});
}
});
}
}
/*
Resource{name='mike', sex='男'}pool-2-thread-1
Resource{name='丽丽', sex='女'}pool-2-thread-1
Resource{name='丽丽', sex='女'}pool-2-thread-1
Resource{name='丽丽', sex='女'}pool-2-thread-1
Resource{name='丽丽', sex='女'}pool-2-thread-1
Resource{name='丽丽', sex='女'}pool-2-thread-1
Resource{name='丽丽', sex='女'}pool-2-thread-1
Resource{name='丽丽', sex='女'}pool-2-thread-1
Resource{name='mike', sex='男'}pool-2-thread-1
Resource{name='mike', sex='男'}pool-2-thread-1
Resource{name='丽丽', sex='女'}pool-2-thread-1
Resource{name='mike', sex='男'}pool-2-thread-1
Resource{name='mike', sex='男'}pool-2-thread-1
Resource{name='mike', sex='男'}pool-2-thread-1
*/
总结:通过hash取模的方式,想清楚数据由哪个线程维护,让它始终操作这个线程,其它线程想要数据,则通过rpc的方式即可。
这时,有些人的做法可能就是:通过wait和notify 及其 甚至synchronized关键字去实现数据的同步,其实这种一旦出现了锁操作,那么就停不下来了,接踵而来就是死锁的问题,这是很容易出问题的,业务层往往可以完全无锁化。游戏服务器中正确的方式就是通过BlockingQueue实现线程之间数据的相互导入,即可。
再比如:游戏服务器中的组队,有可能A线程要数据时,是 3个人,实际上过了几秒后,已经是4个人了,人已经满了,但是客户端还没刷新UI,此时客户端再请求进入,肯定是不成功的,此时由于组队数据始终通过hash始终让一个线程去负责修改,其它线程通过rpc的方式要数据,从而避免了线程安全问题。
再比如在zfoo中,有了rpc的支持,同步的请求后,是可以实现:在web层面虽然是多线程要数据,每次请求都是在不同的线程收到的数据,但是业务逻辑却可以通过rpc的方式在游戏服务器层面进行处理,同时同步的返回,此时就完全再次实现了结合mongodb实现数据存储的实现。